diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index d9cf3d0..0a6f4bd 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -288,9 +288,9 @@ $container = new FrameworkX\Container([ Factory functions used in the container configuration map may also reference variables defined in the container configuration. You may use any object or -scalar value for container variables or factory functions that return any such -value. This can be particularly useful when combining autowiring with some -manual configuration like this: +scalar or `null` value for container variables or factory functions that return +any such value. This can be particularly useful when combining autowiring with +some manual configuration like this: === "Scalar values" diff --git a/src/Container.php b/src/Container.php index ac6a77e..4957b43 100644 --- a/src/Container.php +++ b/src/Container.php @@ -10,10 +10,10 @@ */ class Container { - /** @var array|ContainerInterface */ + /** @var array|ContainerInterface */ private $container; - /** @var array|ContainerInterface $loader */ + /** @var array|ContainerInterface $loader */ public function __construct($loader = []) { if (!\is_array($loader) && !$loader instanceof ContainerInterface) { @@ -24,7 +24,7 @@ public function __construct($loader = []) foreach (($loader instanceof ContainerInterface ? [] : $loader) as $name => $value) { if ( - (!\is_object($value) && !\is_scalar($value)) || + (!\is_object($value) && !\is_scalar($value) && $value !== null) || (!$value instanceof $name && !$value instanceof \Closure && !\is_string($value) && \strpos($name, '\\') !== false) ) { throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (is_object($value) ? get_class($value) : gettype($value))); @@ -125,7 +125,7 @@ public function getErrorHandler(): ErrorHandler */ private function loadObject(string $name, int $depth = 64) /*: object (PHP 7.2+) */ { - if (isset($this->container[$name])) { + if (\array_key_exists($name, $this->container)) { if (\is_string($this->container[$name])) { if ($depth < 1) { throw new \BadMethodCallException('Factory for ' . $name . ' is recursive'); @@ -222,8 +222,8 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool // load container variables if parameter name is known assert($type === null || $type instanceof \ReflectionNamedType); - if ($allowVariables && isset($this->container[$parameter->getName()])) { - return $this->loadVariable($parameter->getName(), $type === null ? 'mixed' : $type->getName(), $depth); + if ($allowVariables && \array_key_exists($parameter->getName(), $this->container)) { + return $this->loadVariable($parameter->getName(), $type === null ? 'mixed' : $type->getName(), $parameter->allowsNull(), $depth); } // abort if parameter is untyped and not explicitly defined by container variable @@ -237,7 +237,7 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool // use default/nullable argument if not loadable as container variable or by type assert($type instanceof \ReflectionNamedType); - if ($hasDefault && ($type->isBuiltin() || !isset($this->container[$type->getName()]))) { + if ($hasDefault && ($type->isBuiltin() || !\array_key_exists($type->getName(), $this->container))) { return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; } @@ -259,12 +259,12 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool } /** - * @return object|string|int|float|bool + * @return object|string|int|float|bool|null * @throws \BadMethodCallException if $name is not a valid container variable */ - private function loadVariable(string $name, string $type, int $depth) /*: object|string|int|float|bool (PHP 8.0+) */ + private function loadVariable(string $name, string $type, bool $nullable, int $depth) /*: object|string|int|float|bool|null (PHP 8.0+) */ { - assert(isset($this->container[$name])); + assert(\array_key_exists($name, $this->container)); if ($this->container[$name] instanceof \Closure) { if ($depth < 1) { throw new \BadMethodCallException('Container variable $' . $name . ' is recursive'); @@ -277,15 +277,20 @@ private function loadVariable(string $name, string $type, int $depth) /*: object // invoke factory with list of parameters $value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params); - if (!\is_object($value) && !\is_scalar($value)) { - throw new \BadMethodCallException('Container variable $' . $name . ' expected type object|scalar from factory, but got ' . \gettype($value)); + if (!\is_object($value) && !\is_scalar($value) && $value !== null) { + throw new \BadMethodCallException('Container variable $' . $name . ' expected type object|scalar|null from factory, but got ' . \gettype($value)); } $this->container[$name] = $value; } $value = $this->container[$name]; - assert(\is_object($value) || \is_scalar($value)); + assert(\is_object($value) || \is_scalar($value) || $value === null); + + // allow null values if parameter is marked nullable or untyped or mixed + if ($nullable && $value === null) { + return null; + } // skip type checks and allow all values if expected type is undefined or mixed (PHP 8+) if ($type === 'mixed') { diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 858158c..7278e41 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -546,7 +546,28 @@ public function __invoke() $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } - public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariable() + public function provideMixedValue() + { + return [ + [ + (object) ['name' => 'Alice'], + '{"name":"Alice"}' + ], + [ + 'Alice', + '"Alice"' + ], + [ + null, + 'null' + ] + ]; + } + + /** + * @dataProvider provideMixedValue + */ + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariable($value, string $json) { $request = new ServerRequest('GET', 'http://example.com/'); @@ -568,7 +589,7 @@ public function __invoke() ResponseInterface::class => function ($data) { return new Response(200, [], json_encode($data)); }, - 'data' => (object) ['name' => 'Alice'] + 'data' => $value ]); $callable = $container->callable(get_class($controller)); @@ -577,10 +598,13 @@ public function __invoke() $response = $callable($request); $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + $this->assertEquals($json, (string) $response->getBody()); } - public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariableWithFactory() + /** + * @dataProvider provideMixedValue + */ + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariableWithFactory($value, string $json) { $request = new ServerRequest('GET', 'http://example.com/'); @@ -602,8 +626,8 @@ public function __invoke() ResponseInterface::class => function ($data) { return new Response(200, [], json_encode($data)); }, - 'data' => function () { - return (object) ['name' => 'Alice']; + 'data' => function () use ($value) { + return $value; } ]); @@ -613,13 +637,14 @@ public function __invoke() $response = $callable($request); $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + $this->assertEquals($json, (string) $response->getBody()); } /** * @requires PHP 8 + * @dataProvider provideMixedValue */ - public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariable() + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariable($value, string $json) { $request = new ServerRequest('GET', 'http://example.com/'); @@ -641,7 +666,7 @@ public function __invoke() ResponseInterface::class => function (mixed $data) { return new Response(200, [], json_encode($data)); }, - 'data' => (object) ['name' => 'Alice'] + 'data' => $value ]); $callable = $container->callable(get_class($controller)); @@ -650,13 +675,14 @@ public function __invoke() $response = $callable($request); $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + $this->assertEquals($json, (string) $response->getBody()); } /** * @requires PHP 8 + * @dataProvider provideMixedValue */ - public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariableWithFactory() + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariableWithFactory($value, string $json) { $request = new ServerRequest('GET', 'http://example.com/'); @@ -678,8 +704,8 @@ public function __invoke() ResponseInterface::class => function (mixed $data) { return new Response(200, [], json_encode($data)); }, - 'data' => function () { - return (object) ['name' => 'Alice']; + 'data' => function () use ($value) { + return $value; } ]); @@ -689,7 +715,77 @@ public function __invoke() $response = $callable($request); $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + $this->assertEquals($json, (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariableWithIntDefaultAssignExplicitNullValue() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new Response()) { + private $response; + + public function __construct(ResponseInterface $response) + { + $this->response = $response; + } + + public function __invoke() + { + return $this->response; + } + }; + + $container = new Container([ + ResponseInterface::class => function ($data = 42) { + return new Response(200, [], json_encode($data)); + }, + 'data' => null + ]); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('null', (string) $response->getBody()); + } + + /** + * @requires PHP 8 + */ + public function testCallableReturnsCallableForClassWithDependencyMappedWithFactoryThatRequiresMixedContainerVariableWithIntDefaultAssignExplicitNullValue() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new Response()) { + private $response; + + public function __construct(ResponseInterface $response) + { + $this->response = $response; + } + + public function __invoke() + { + return $this->response; + } + }; + + $fn = #[PHP8] fn(mixed $data = 42) => new Response(200, [], json_encode($data)); + $container = new Container([ + ResponseInterface::class => $fn, + 'data' => null + ]); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('null', (string) $response->getBody()); } public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresNullableContainerVariables() @@ -1030,7 +1126,7 @@ public function __invoke(ServerRequestInterface $request) $callable = $container->callable(get_class($controller)); $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Container variable $http expected type object|scalar from factory, but got resource'); + $this->expectExceptionMessage('Container variable $http expected type object|scalar|null from factory, but got resource'); $callable($request); } @@ -1281,6 +1377,64 @@ public function __invoke(ServerRequestInterface $request) $callable($request); } + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesClassNameButGetsNullVariable() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new \stdClass()) { + private $data; + + public function __construct(\stdClass $data) + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + \stdClass::class => null + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Map for stdClass contains unexpected NULL'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesNullableClassNameButGetsNullVariable() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new \stdClass()) { + private $data; + + public function __construct(?\stdClass $data) + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + \stdClass::class => null + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Map for stdClass contains unexpected NULL'); + $callable($request); + } + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesClassMappedToUnexpectedObject() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -1349,33 +1503,33 @@ public function testCtorThrowsWhenMapContainsInvalidArray() ]); } - public function testCtorThrowsWhenMapContainsInvalidNull() + public function testCtorThrowsWhenMapContainsInvalidResource() { $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Map for user contains unexpected NULL'); + $this->expectExceptionMessage('Map for file contains unexpected resource'); new Container([ - 'user' => null + 'file' => tmpfile() ]); } - public function testCtorThrowsWhenMapContainsInvalidResource() + public function testCtorThrowsWhenMapForClassContainsInvalidObject() { $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Map for file contains unexpected resource'); + $this->expectExceptionMessage('Map for Psr\Http\Message\ResponseInterface contains unexpected stdClass'); new Container([ - 'file' => tmpfile() + ResponseInterface::class => new \stdClass() ]); } - public function testCtorThrowsWhenMapForClassContainsInvalidObject() + public function testCtorThrowsWhenMapForClassContainsInvalidNull() { $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Map for Psr\Http\Message\ResponseInterface contains unexpected stdClass'); + $this->expectExceptionMessage('Map for Psr\Http\Message\ResponseInterface contains unexpected NULL'); new Container([ - ResponseInterface::class => new \stdClass() + ResponseInterface::class => null ]); }