Skip to content

Commit

Permalink
API changes, delegate support, resolve params by name only in factories
Browse files Browse the repository at this point in the history
  • Loading branch information
jrabausch committed Jan 2, 2025
1 parent 0334e08 commit e156ca7
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 75 deletions.
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@ Container requires PHP 8.0+
## Interface

```php
new Container(iterable $definitions = [], bool $autowire = true)
new Container(iterable $definitions = [])
```

The container ships with four public methods:

```php
with(string $id, $entry): Container // add a container entry
get(string $id) // get entry (PSR-11)
withAutowiring(bool $flag): Container // toggle autowiring
withEntry(string $id, mixed $entry): Container // add a container entry
withDelegate(ContainerInterface $delegate): Container // register a delegate container
get(string $id): mixed // get entry (PSR-11)
has(string $id): bool // has entry (PSR-11)
create(string $id, array $params = []); // create a class with optional constructor substitution args
create(string $id, array $params = []): mixed // create a class with optional constructor substitution args
entries(): array // list all container entries
```

Expand Down Expand Up @@ -70,7 +72,7 @@ $hello === $hello2 // true
$hello->print(); // 'Hello World'
```

Note that the container only creates instances once. It does not work as a factory.
Note that the container only creates (shared) instances once. It does not work as a factory.
You should consider the [Factory Pattern](https://designpatternsphp.readthedocs.io/en/latest/Creational/SimpleFactory/README.html) or use the ```create()``` method instead:

```php
Expand Down Expand Up @@ -99,7 +101,7 @@ The ```create()``` method will automatically resolve the ```Config``` dependency

## Configuration

You can configure the container with definitions. ```Callables``` (except invokable objects) are always treated as factories and can (!should) be used to bootstrap class instances:
You can configure the container with definitions. ```Closures``` are always treated as factories and should be used to bootstrap class instances. If you like to use ```callables``` as factories: ```Closure::fromCallable([$object, 'method'])```.

```php
use Semperton\Container\Container;
Expand Down Expand Up @@ -128,17 +130,17 @@ $container->get('closure')(); // 42
$container->get(MailFactory::class); // instance of MailFactory
```

The ```with()``` method also treats ```callables``` as factories.
The ```withEntry()``` method also treats ```callables``` as factories.

## Immutability

Once the container is created, it is immutable. If you like to add an entry after instantiation, keep in mind that the ```with()``` method always returns a new container instance:
Once the container is created, it is immutable. If you like to add an entry after instantiation, keep in mind that the ```withEntry()``` method always returns a new container instance:

```php
use Semperton\Container\Container;

$container1 = new Container();
$container2 = $container1->with('number', 42);
$container2 = $container1->withEntry('number', 42);

$container1->has('number'); // false
$container2->has('number'); // true
Expand Down
126 changes: 72 additions & 54 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,16 @@
use const SORT_NATURAL;
use const SORT_FLAG_CASE;

use function is_callable;
use function class_exists;
use function array_key_exists;
use function array_keys;
use function array_unique;
use function array_merge;
use function sort;

final class Container implements ContainerInterface, FactoryInterface
{
/**
* @var array<string, callable|Closure>
* @var array<string, Closure>
*/
protected array $factories = [];

Expand All @@ -48,32 +46,53 @@ final class Container implements ContainerInterface, FactoryInterface
*/
protected array $resolving = [];

protected bool $autowire = true;

protected ?ContainerInterface $delegate = null;

/**
* @param iterable<string, mixed> $definitions
* @param iterable<string|class-string, mixed> $definitions
*/
public function __construct(
iterable $definitions = [],
protected bool $autowire = true
) {
public function __construct(iterable $definitions = [])
{
$this->set(self::class, $this);
$this->set(ContainerInterface::class, $this);

/** @var mixed $entry */
foreach ($definitions as $id => $entry) {
$this->set($id, $entry);
}
}

/**
* @param mixed $entry
*/
protected function set(string $id, mixed $entry): void
{
unset($this->factories[$id], $this->cache[$id], $this->entries[$id]);

if ($entry instanceof Closure || (is_callable($entry) && !is_object($entry))) {
/** @var callable|Closure */
if ($entry instanceof Closure) {
$this->factories[$id] = $entry;
} else {
$this->entries[$id] = $entry;
}
}

public function with(string $id, mixed $entry): Container
public function withAutowiring(bool $flag): Container
{
$container = clone $this;
$container->autowire = $flag;
return $container;
}

public function withDelegate(ContainerInterface $delegate): Container
{
$container = clone $this;
$container->delegate = $delegate;
return $container;
}

public function withEntry(string $id, mixed $entry): Container
{
$container = clone $this;
$container->set($id, $entry);
Expand All @@ -91,16 +110,12 @@ public function get(string $id): mixed
return $this->entries[$id];
}

if ($id === self::class || $id === ContainerInterface::class) {
return $this;
}

if ($this->autowire) {
$this->entries[$id] = $this->create($id);
return $this->entries[$id];
if ($this->delegate?->has($id)) {
return $this->delegate->get($id);
}

throw new NotFoundException("Entry for < $id > could not be resolved");
$this->entries[$id] = $this->create($id);
return $this->entries[$id];
}

/**
Expand All @@ -113,63 +128,66 @@ public function create(string $id, array $params = []): mixed
}

if (isset($this->factories[$id])) {
$this->cache[$id] = $this->getFactoryClosure($this->factories[$id]);
$this->cache[$id] = $this->getClosureFactory($this->factories[$id]);
return $this->resolve($id, $params);
}

if ($this->canCreate($id)) {
/** @var class-string $id */
$this->cache[$id] = $this->getClassFactory($id);
return $this->resolve($id, $params);
}

throw new NotFoundException("Factory or class for < $id > could not be found");
throw new NotFoundException("Entry, factory or class for < $id > could not be resolved");
}

protected function resolve(string $id, array $params = []): mixed
protected function resolve(string $id, array $params): mixed
{
if (isset($this->resolving[$id])) {
throw new CircularReferenceException("Circular reference detected for < $id >");
}

$this->resolving[$id] = true;
try {
if (isset($this->resolving[$id])) {
$entries = array_keys($this->resolving);
$path = implode(' -> ', [...$entries, $id]);
throw new CircularReferenceException("Circular reference detected: $path");
}

/** @var mixed */
$entry = $this->cache[$id]($params);
$this->resolving[$id] = true;

unset($this->resolving[$id]);
/** @var mixed */
$entry = $this->cache[$id]($params);
} finally {
unset($this->resolving[$id]);
}

return $entry;
}

protected function getFactoryClosure(callable $callable): Closure
protected function getClosureFactory(Closure $closure): Closure
{
$closure = Closure::fromCallable($callable);

$function = new ReflectionFunction($closure);

$params = $function->getParameters();

return function () use ($function, $params): mixed {
$args = $this->resolveFunctionParams($params);
return $function->invokeArgs($args);
return function (array $args) use ($function, $params): mixed {
$newArgs = $this->resolveFunctionParams($params, $args, true);
return $function->invokeArgs($newArgs);
};
}

/**
* @param class-string $name
*/
protected function getClassFactory(string $name): Closure
{
/** @psalm-suppress ArgumentTypeCoercion */
$class = new ReflectionClass($name);

if (!$class->isInstantiable()) {
throw new NotInstantiableException("Unable to create < $name >, not instantiable");
}

$constructor = $class->getConstructor();
$params = $constructor ? $constructor->getParameters() : [];
$params = $constructor?->getParameters() ?? [];

return function (array $args) use ($class, $params) {

$newArgs = $this->resolveFunctionParams($params, $args);
$newArgs = $this->resolveFunctionParams($params, $args, false);
return $class->newInstanceArgs($newArgs);
};
}
Expand All @@ -178,40 +196,37 @@ protected function getClassFactory(string $name): Closure
* @param array<array-key, ReflectionParameter> $params
* @return array<int, mixed>
*/
protected function resolveFunctionParams(array $params, array $replace = []): array
protected function resolveFunctionParams(array $params, array $replace, bool $allowNames): array
{
$args = [];

foreach ($params as $param) {

$paramName = $param->getName();

if ($replace && (isset($replace[$paramName]) || array_key_exists($paramName, $replace))) {

if (isset($replace[$paramName]) || array_key_exists($paramName, $replace)) {
/** @var mixed */
$args[] = $replace[$paramName];
continue;
}

/** @var null|ReflectionNamedType */
$type = $param->getType();

if ($type && !$type->isBuiltin()) {
// we do not support union / intersection types for now
if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
$className = $type->getName();
/** @var mixed */
$args[] = $this->get($className);
continue;
}

if ($this->has($paramName)) {

if ($allowNames && $this->has($paramName)) {
/** @var mixed */
$args[] = $this->get($paramName);
continue;
}

if ($param->isOptional()) {

/** @var mixed */
$args[] = $param->getDefaultValue();
continue;
Expand All @@ -229,7 +244,7 @@ protected function resolveFunctionParams(array $params, array $replace = []): ar

protected function canCreate(string $name): bool
{
return class_exists($name);
return $this->autowire && class_exists($name);
}

public function has(string $id): bool
Expand All @@ -238,13 +253,16 @@ public function has(string $id): bool
isset($this->entries[$id]) ||
isset($this->factories[$id]) ||
isset($this->cache[$id]) ||
array_key_exists($id, $this->entries) ||
$this->canCreate($id)
array_key_exists($id, $this->entries)
) {
return true;
}

return false;
if ($this->delegate?->has($id)) {
return true;
}

return $this->canCreate($id);
}

/**
Expand All @@ -254,7 +272,7 @@ public function entries(): array
{
$entries = array_keys($this->entries);
$factories = array_keys($this->factories);
$combined = array_unique(array_merge($entries, $factories));
$combined = array_unique([...$entries, ...$factories]);

sort($combined, SORT_NATURAL | SORT_FLAG_CASE);

Expand Down
4 changes: 3 additions & 1 deletion tests/ContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public function testContainerImmutability()
{
$container = new Container();
$oldContainer = $container;
$newContainer = $container->with('foo', 'bar');
$newContainer = $container->withEntry('foo', 'bar');
$this->assertEquals($container, $oldContainer);
$this->assertNotEquals($container, $newContainer);
$this->assertEquals('bar', $newContainer->get('foo'));
Expand All @@ -145,6 +145,8 @@ public function testListEntries()
$expected = [
'bar',
'foo',
ContainerInterface::class,
Container::class,
DepA::class,
DepB::class,
DepC::class
Expand Down
21 changes: 11 additions & 10 deletions tests/CreateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,29 @@ public function testParameterException()
$this->expectException(ParameterResolveException::class);

$container = new Container();
$c = $container->create(DepC::class);
$container->create(DepC::class);
}

public function testCreateArgs()
public function testParameterException2()
{
$container = new Container();
$c = $container->create(DepC::class, [
$this->expectException(ParameterResolveException::class);

$container = new Container([
'name' => 'Semperton'
]);

$this->assertInstanceOf(DepC::class, $c);
$this->assertInstanceOf(DepB::class, $c->b);
$this->assertEquals('Semperton', $c->name);
$container->get(DepC::class);
}

public function testCreateAutoResolve()
public function testCreateArgs()
{
$container = new Container([
$container = new Container();
$c = $container->create(DepC::class, [
'name' => 'Semperton'
]);

$c = $container->get(DepC::class);
$this->assertInstanceOf(DepC::class, $c);
$this->assertInstanceOf(DepB::class, $c->b);
$this->assertEquals('Semperton', $c->name);
}
}
Loading

0 comments on commit e156ca7

Please sign in to comment.