Skip to content

Commit

Permalink
Merge pull request #178 from clue-labs/container-strings
Browse files Browse the repository at this point in the history
Support string variables in container configuration for factory functions
  • Loading branch information
clue authored Jul 29, 2022
2 parents 440cf7e + 350cd67 commit 053ab66
Show file tree
Hide file tree
Showing 3 changed files with 450 additions and 25 deletions.
33 changes: 32 additions & 1 deletion docs/best-practices/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ the dependency injection container like this:
require __DIR__ . '/../vendor/autoload.php';

$container = new FrameworkX\Container([
Acme\Todo\HelloController::class => fn() => new Acme\Todo\HelloController();
Acme\Todo\HelloController::class => fn() => new Acme\Todo\HelloController()
]);


Expand Down Expand Up @@ -284,6 +284,37 @@ $container = new FrameworkX\Container([
// …
```

Factory functions used in the container configuration map may also reference
string variables defined in the container configuration. You may also use
factory functions that return string variables. This can be particularly useful
when combining autowiring with some manual configuration like this:

```php title="public/index.php"
<?php

require __DIR__ . '/../vendor/autoload.php';

$container = new FrameworkX\Container([
Acme\Todo\UserController::class => function (string $name, string $hostname) {
// example UserController class requires two string arguments
return new Acme\Todo\UserController($name, $hostname);
},
'name' => 'Acme',
'hostname' => fn(): string => gethostname()
]);

// …
```

> ℹ️ **Avoiding name conflicts**
>
> Note that class names and string variables share the same container
> configuration map and as such might be subject to name collisions as a single
> entry may only have a single value. For this reason, container variables will
> only be used for container functions by default. We highly recommend using
> namespaced class names like in the previous example. You may also want to make
> sure that container variables use unique names prefixed with your vendor name.
The container configuration may also be used to map a class name to a different
class name that implements the same interface, either by mapping between two
class names or using a factory function that returns a class name. This is
Expand Down
97 changes: 76 additions & 21 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
*/
class Container
{
/** @var array<class-string,object|callable():(object|class-string)>|ContainerInterface */
/** @var array<string,object|callable():(object|string)|string>|ContainerInterface */
private $container;

/** @var array<class-string,callable():(object|class-string) | object | class-string>|ContainerInterface $loader */
/** @var array<string,callable():(object|string) | object | string>|ContainerInterface $loader */
public function __construct($loader = [])
{
if (!\is_array($loader) && !$loader instanceof ContainerInterface) {
Expand All @@ -23,11 +23,7 @@ public function __construct($loader = [])
}

foreach (($loader instanceof ContainerInterface ? [] : $loader) as $name => $value) {
if (\is_string($value)) {
$loader[$name] = static function () use ($value) {
return $value;
};
} elseif (!$value instanceof \Closure && !$value instanceof $name) {
if (!\is_string($value) && !$value instanceof \Closure && !$value instanceof $name) {
throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
}
}
Expand Down Expand Up @@ -67,7 +63,7 @@ public function callable(string $class): callable
if ($this->container instanceof ContainerInterface) {
$handler = $this->container->get($class);
} else {
$handler = $this->load($class);
$handler = $this->loadObject($class);
}
} catch (\Throwable $e) {
throw new \BadMethodCallException(
Expand Down Expand Up @@ -102,7 +98,7 @@ public function getAccessLogHandler(): AccessLogHandler
return new AccessLogHandler();
}
}
return $this->load(AccessLogHandler::class);
return $this->loadObject(AccessLogHandler::class);
}

/** @internal */
Expand All @@ -115,21 +111,33 @@ public function getErrorHandler(): ErrorHandler
return new ErrorHandler();
}
}
return $this->load(ErrorHandler::class);
return $this->loadObject(ErrorHandler::class);
}

/**
* @param class-string $name
* @return object
* @throws \BadMethodCallException
* @template T
* @param class-string<T> $name
* @return T
* @throws \BadMethodCallException if object of type $name can not be loaded
*/
private function load(string $name, int $depth = 64)
private function loadObject(string $name, int $depth = 64) /*: object (PHP 7.2+) */
{
if (isset($this->container[$name])) {
if ($this->container[$name] instanceof \Closure) {
if (\is_string($this->container[$name])) {
if ($depth < 1) {
throw new \BadMethodCallException('Factory for ' . $name . ' is recursive');
}

$value = $this->loadObject($this->container[$name], $depth - 1);
if (!$value instanceof $name) {
throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
}

$this->container[$name] = $value;
} elseif ($this->container[$name] instanceof \Closure) {
// build list of factory parameters based on parameter types
$closure = new \ReflectionFunction($this->container[$name]);
$params = $this->loadFunctionParams($closure, $depth);
$params = $this->loadFunctionParams($closure, $depth, true);

// invoke factory with list of parameters
$value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params);
Expand All @@ -139,14 +147,17 @@ private function load(string $name, int $depth = 64)
throw new \BadMethodCallException('Factory for ' . $name . ' is recursive');
}

$value = $this->load($value, $depth - 1);
} elseif (!$value instanceof $name) {
$value = $this->loadObject($value, $depth - 1);
}
if (!$value instanceof $name) {
throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
}

$this->container[$name] = $value;
}

assert($this->container[$name] instanceof $name);

return $this->container[$name];
}

Expand All @@ -170,13 +181,14 @@ private function load(string $name, int $depth = 64)

// build list of constructor parameters based on parameter types
$ctor = $class->getConstructor();
$params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth);
$params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth, false);

// instantiate with list of parameters
return $this->container[$name] = $params === [] ? new $name() : $class->newInstance(...$params);
}

private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth): array
/** @throws \BadMethodCallException if either parameter can not be loaded */
private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth, bool $allowVariables): array
{
$params = [];
foreach ($function->getParameters() as $parameter) {
Expand Down Expand Up @@ -206,6 +218,14 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $
}

assert($type instanceof \ReflectionNamedType);

// load string variables from container
if ($allowVariables && $type->getName() === 'string') {
$params[] = $this->loadVariable($parameter->getName(), $depth);
continue;
}

// abort for other primitive types
if ($type->isBuiltin()) {
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName());
}
Expand All @@ -215,12 +235,47 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $
throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive');
}

$params[] = $this->load($type->getName(), $depth - 1);
$params[] = $this->loadObject($type->getName(), $depth - 1);
}

return $params;
}

/** @throws \BadMethodCallException if $name is not a valid string variable */
private function loadVariable(string $name, int $depth): string
{
if (!isset($this->container[$name])) {
throw new \BadMethodCallException('Container variable $' . $name . ' is not defined');
}

if ($this->container[$name] instanceof \Closure) {
if ($depth < 1) {
throw new \BadMethodCallException('Container variable $' . $name . ' is recursive');
}

// build list of factory parameters based on parameter types
$closure = new \ReflectionFunction($this->container[$name]);
$params = $this->loadFunctionParams($closure, $depth - 1, true);

// invoke factory with list of parameters
$value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params);

if (!\is_string($value)) {
throw new \BadMethodCallException('Container variable $' . $name . ' expected type string from factory, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value)));
}

$this->container[$name] = $value;
}

$value = $this->container[$name];
if (!\is_string($value)) {
throw new \BadMethodCallException('Container variable $' . $name . ' expected type string, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value)));
}

return $value;
}

/** @throws void */
private static function parameterError(\ReflectionParameter $parameter): string
{
$name = $parameter->getDeclaringFunction()->getShortName();
Expand Down
Loading

0 comments on commit 053ab66

Please sign in to comment.