divineniiquaye/rade-di

View on GitHub
src/Container.php

Summary

Maintainability
B
4 hrs
Test Coverage
A
100%
<?php

declare(strict_types=1);

/*
 * This file is part of DivineNii opensource projects.
 *
 * PHP version 7.4 and above required
 *
 * @author    Divine Niiquaye Ibok <divineibok@gmail.com>
 * @copyright 2021 DivineNii (https://divinenii.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Rade\DI;

use Psr\Container\{ContainerInterface, NotFoundExceptionInterface};
use Rade\DI\Exceptions\{CircularReferenceException, ContainerResolutionException, NotFoundServiceException};
use Symfony\Contracts\Service\ResetInterface;

/**
 * Dependency injection container.
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
class Container implements \ArrayAccess, ContainerInterface, ResetInterface
{
    use Traits\ParameterTrait, Traits\DefinitionTrait, Traits\TagsTrait, Traits\TypesTrait, Traits\ExtensionTrait;

    /** @final The reserved service id for container's instance */
    public const SERVICE_CONTAINER = 'container';

    /** Sets the behaviour to ignore exception on types with multiple services */
    public const IGNORE_MULTIPLE_SERVICE = 0;

    /** Set a strict behaviour to thrown an exception on types with multiple services */
    public const EXCEPTION_ON_MULTIPLE_SERVICE = 1;

    /** Instead of throwing an exception, null will return if service not found */
    public const NULL_ON_INVALID_SERVICE = 2;

    /** @var null|\WeakMap<ContainerInterface,true> A list of PSR-11 containers */
    protected ?\WeakMap $containers = null;

    public function __construct()
    {
        if (!isset($this->types[$cl = static::class])) {
            $this->type(self::SERVICE_CONTAINER, ...\array_keys(\class_implements($c = $this) + \class_parents($c) + [$cl => $cl]));
        }
        $this->resolver = new Resolver($this->services[self::SERVICE_CONTAINER] = $c ?? $this);
    }

    /**
     * Container can not be cloned.
     */
    public function __clone()
    {
        throw new \LogicException('Container is not cloneable');
    }

    /**
     * Alias of the container resolver's resolve method.
     *
     * @param array<int|string,mixed> $args
     */
    public function __invoke(mixed $value, array $args = []): mixed
    {
        return $this->resolver->resolve($value, $args);
    }

    /**
     * Sets a new service to a unique identifier.
     *
     * @param string $offset The unique identifier for the parameter or object
     * @param mixed  $value  The value of the service assign to the $offset
     *
     * @throws Exceptions\FrozenServiceException Prevent override of a frozen service
     */
    public function offsetSet(mixed $offset, mixed $value): void
    {
        $this->autowire($offset, $value);
    }

    /**
     * Gets a registered service definition.
     *
     * @param string $offset The unique identifier for the service
     *
     * @throws NotFoundServiceException If the identifier is not defined
     *
     * @return mixed The value of the service
     */
    public function offsetGet(mixed $offset): mixed
    {
        return $this->get($offset);
    }

    /**
     * Checks if a service is set.
     *
     * @param string $offset The unique identifier for the service
     */
    public function offsetExists(mixed $offset): bool
    {
        return $this->has($offset);
    }

    /**
     * Unset a service by given offset.
     *
     * @param string $offset The unique identifier for service definition
     */
    public function offsetUnset(mixed $offset): void
    {
        $this->removeDefinition($offset);
    }

    /**
     * Resets the container.
     */
    public function reset(): void
    {
        foreach ($this->definitions as $id => $service) {
            $service = $this->services[$id] ?? $service;

            if ($service instanceof ResetInterface) {
                $service->reset();
            }
            $this->removeDefinition($id);
        }

        foreach ($this->containers ?? [] as $container => $true) {
            if ($container instanceof ResetInterface) {
                $container->reset(); // A container such as Symfony DI support reset
            }
        }

        $c = $this->services[self::SERVICE_CONTAINER];
        $t = $this->typed(self::SERVICE_CONTAINER, true);
        $this->services = $this->types = $this->tags = $this->aliases = [];

        $this->services[self::SERVICE_CONTAINER] = $c;
        $this->type(self::SERVICE_CONTAINER, ...$t);
    }

    /**
     * Attach an existing container, useful for migration purposes.
     */
    public function attach(ContainerInterface $container): void
    {
        if (null === $this->containers) {
            $this->containers = new \WeakMap();
        }
        $this->containers[$container] = true;
    }

    /**
     * Detach an existing container if it's no longer needed.
     */
    public function detach(ContainerInterface $container): void
    {
        if (null !== $this->containers) {
            unset($this->containers[$container]);
        }
    }

    /**
     * {@inheritdoc}
     */
    public function has(string $id): bool
    {
        if (static::SERVICE_CONTAINER === $id) {
            return true;
        }

        if (false !== ($this->aliases[$id] ?? $this->methodsMap[$id] ?? \array_key_exists($id, $this->definitions))) {
            return true;
        }

        foreach ($this->containers ?? [] as $container => $true) {
            if ($container->has($id)) {
                return $true;
            }
        }

        return false;
    }

    /**
     * {@inheritdoc}
     *
     * @throws CircularReferenceException When a circular reference is detected
     * @throws NotFoundServiceException   When the service is not defined
     */
    public function get(string $id, int $invalidBehavior = /* self::EXCEPTION_ON_MULTIPLE_SERVICE */ 1): mixed
    {
        return $this->services[$id]
            ?? $this->services[$id = $this->aliases[$id] ?? $id]
            ?? $this->{$this->methodsMap[$id] ?? 'doLoad'}($id, $invalidBehavior);
    }

    /**
     * Invokes given closure or function withing specific container scope.
     * By default, container is passed into callback arguments.
     *
     * Example:
     * ```php
     * $container->autowire('movie', new Movie('Baby Driver 2023'));
     * $container->runScope(
     *    ['actor', 'director'],
     *    function (ContainerInterface $container, Movie $movie) {
     *        $container->set('director', new Director('Edgar Wright'));
     *        $container->set('actor', new Actor('John Doe'));
     *
     *        $movie->addActor($container->get('actor'));
     *        $movie->setDirector($container->get('director'));
     *
     *        return $movie;
     *    }
     * );
     * ```
     *
     * This makes the service private and cannot be use elsewhere in codebase.
     *
     * @param array<int,string> $services
     *
     * @throws ContainerResolutionException if a service id exists
     */
    public function runScope(array $services, callable $scope): mixed
    {
        $ref = new \ReflectionFunction(\Closure::fromCallable($scope));

        foreach ($services as $serviceId) {
            if ($this->has($serviceId)) {
                throw new ContainerResolutionException(\sprintf('Service with id "%s" exist in container and cannot be redeclared.', $serviceId));
            }
        }

        try {
            return 0 === $ref->getNumberOfParameters() ? $ref->invoke() : $ref->invokeArgs($this->resolver->autowireArguments($ref));
        } finally {
            foreach ($services as $alias) {
                if (!$this->has($alias)) {
                    throw new NotFoundServiceException(\sprintf('Service with id "%s" was not found, cannot remove it.', $alias));
                }
                $this->removeDefinition($alias);
            }
        }
    }

    /**
     * Return a list of definitions belonging to a type or tag.
     *
     * @return array<int,string> The list of service definitions ids
     */
    public function findBy(string $typeOrTag, callable $resolve = null): array
    {
        if (\array_key_exists($typeOrTag, $this->tags)) {
            $tags = \array_keys($this->tags[$typeOrTag]);
        }
        $definitions = $tags ?? $this->types[$typeOrTag] ?? [];

        return null === $resolve ? $definitions : \array_map($resolve, $definitions);
    }

    /**
     * Load the service definition.
     */
    protected function doLoad(string $id, int $invalidBehavior): mixed
    {
        if (null == ($definition = $this->definitions[$id] ?? null)) {
            if (\array_key_exists($id, $this->types)) {
                return $this->autowired($id, 1 === ($invalidBehavior & self::EXCEPTION_ON_MULTIPLE_SERVICE));
            }

            foreach ($this->containers ?? [] as $container => $true) {
                if ($container->has($id)) {
                    try {
                        return $container->get($id);
                    } catch (NotFoundExceptionInterface $e) {
                        // Skip error ...
                    }
                }
            }

            return 2 === ($invalidBehavior & self::NULL_ON_INVALID_SERVICE) ? null : throw $this->createNotFound($id, $e ?? null);
        }

        if ($definition->isAbstract()) {
            throw new ContainerResolutionException(\sprintf('Abstract definition "%s" cannot be instantiated.', $id));
        }

        try {
            $this->loading[$id] = !isset($this->loading[$id]) ? true : throw new CircularReferenceException($id, [...\array_keys($this->loading), $id]);
            $service = $definition->resolve($this->resolver);

            return !$definition->isShared() ? $service : $this->services[$id] = $service;
        } finally {
            unset($this->loading[$id]);

            if (!$definition->isPublic() && !$this instanceof ContainerBuilder) {
                $this->removeDefinition($id);
            }
        }
    }
}