Newer
Older
lib-di-container / src / Container / Container.php
@nightfall nightfall on 4 Jul 2023 10 KB docs
<?php

namespace Uid\Utils\Container;

use Psr\Container\ContainerInterface as PsrContainerInterface;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use ReflectionMethod;
use ReflectionParameter;
use function array_key_exists;
use function array_map;
use function class_exists;
use function class_implements;
use function class_parents;
use function count;
use function get_class;
use function in_array;
use function is_object;
use function is_string;
use function method_exists;
use function sprintf;

/** @inheritdoc*/
class Container implements ContainerInterface
{
    private array $definitions;
    /**
     * Container constructor.
     *
     * @param array $definitions Predefined definitions for the container (default: []).
     */
    public function __construct(array $definitions = [])
    {
        $this->definitions = $definitions;
        $this->definitions[ContainerInterface::class] = ['alias' => Container::class];
        $this->definitions[PsrContainerInterface::class] = ['alias' => Container::class];
        $this->definitions[self::class] = ['value' => $this];
    }
    /**
     * Define a value to be stored in the container.
     *
     * @param string $name The name of the value.
     * @param mixed $value The value to store.
     * @return Container The container instance.
     */
    public function value(string $name, $value): self
    {
        $this->definitions[$name] = ['value' => $value];
        return $this;
    }
    /**
     * Define an alias for a name in the container.
     *
     * @param string $name The name of the alias.
     * @param string $alias The name of the entry to alias.
     * @return Container The container instance.
     */
    public function alias(string $name, string $alias): self
    {
        $this->definitions[$name] = ['alias' => $alias];
        return $this;
    }
    /**
     * Defines a class and store it in a container.
     *
     * @param string $class The name of the class.
     * @param array $constants Parameters as an associative array to pass to the constructor (default: []).
     * @param bool $singleton Whether the instance should be treated as a singleton (default: false).
     * @return Container The container instance.
     */
    public function create(string $class, array $constants = [], bool $singleton = false): self
    {
        return $this->set($class, $class, $constants, $singleton);
    }
    /**
     * Define a factory callback to create instances in the container.
     *
     * @param string $name The name of the entry.
     * @param callable $callback The factory callback.
     * @param array $constants Parameters as an associative array to pass to the callback (default: []).
     * @param bool $singleton Whether the instance should be treated as a singleton (default: false).
     * @return Container The container instance.
     */
    public function factory(string $name, callable $callback, array $constants = [], bool $singleton = false): self
    {
        return $this->set($name, $callback, $constants, $singleton);;
    }
    /**
     * Define an entry in the container.
     *
     * @param string $name The name of the entry.
     * @param string|callable $value The value or factory callback for the entry.
     * @param array $constants Parameters as an associative array to pass to the constructor or callback (default: []).
     * @param bool $singleton Whether the instance should be treated as a singleton (default: false).
     * @return Container The container instance.
     */
    public function set(string $name, string|callable $value, array $constants = [], bool $singleton = false): self
    {
        $this->definitions[$name] = [
            'callback' => is_string($value) 
                ? $this->constructor($value, $constants)
                : $this->callback($value, $constants), 
            'singleton' => $singleton
        ];
        return $this;
    }
    /** @inheritdoc*/
    public function has(string $id): bool
    {
        return $this->definition($this->resolve($id)) !== null;
    }
    /** @inheritdoc*/
    public function get(string $id): mixed
    {
        $name = $this->resolve($id);
        $definition = $this->definition($name);
        if ($definition === null) {
            throw new NotFoundException(sprintf("Cannot find definition for '%s'", $name));
        }
        if (array_key_exists('value', $definition)) {
            return $definition['value'];
        }
        $result = ($definition['callback'])($this);
        if (isset($definition['singleton'])) {
            $this->definitions[$name]['value'] = $result;
        }
        return $result;
    }
    /** @inheritdoc*/
    public function invoke(string|object $entry, string $method = '__invoke', array $constants = []): mixed
    {
        $object = is_string($entry) ? $this->get($entry) : $entry;
        if (!is_object($object)) {
            throw new ContainerException(sprintf("Unable to invoke method '%s' not exists for non-object '%s'", $method, $entry));
        }
        if (!method_exists($object, $method)) {
            throw new NotFoundException(sprintf("Method '%s' does not exist for instance of '%s'", $method, get_class($object)));
        }
        try {
            $reflection = new ReflectionMethod($object, $method);
        } catch (ReflectionException $ex) {
            throw new ContainerException(sprintf("Unable to invoke method '%s' for instance of '%s'", $method, get_class($object)), 0, $ex);
        }
        $arguments = array_map(fn($parameter) => $this->argument($parameter, $constants), $reflection->getParameters());
        return $object->$method(... array_map(fn($arg) => $arg[0] ? $this->get($arg[1]): $arg[1], $arguments));
    }
    /**
     * Get the definition for the given entry name.
     *
     * @param string $name The name of the entry.
     * @return array|null The definition array if found, null otherwise.
     */
    private function definition(string $name): ?array
    {
        if (array_key_exists($name, $this->definitions)) {
            return $this->definitions[$name];
        } elseif (class_exists($name)) {
            $this->definitions[$name] = ['callback' => $this->constructor($name), 'singleton' => false];
            return $this->definitions[$name]; 
        }
        return null;
    }
    /**
     * Resolve an entry name by following any aliases.
     *
     * @param string $id The name of the entry.
     * @return string The resolved name.
     */
    private function resolve(string $id): string
    {
        while (array_key_exists($id, $this->definitions)) {
            if (array_key_exists('alias', $this->definitions[$id])) {
                $id = $this->definitions[$id]['alias'];
            } else {
                return $id;
            }
        }
        return $id;
    }
    /**
     * Create a constructor callback for the given class.
     *
     * @param string $class The name of the class.
     * @param array $constants Parameters as an associative array to pass to the constructor (default: []).
     * @return callable The constructor callback.
     * @throws ContainerException If the class cannot be instantiated.
     */
    private function constructor(string $class, array $constants = []): callable
    {
        try {
            $reflection = new ReflectionClass($class);
        } catch (ReflectionException $e) {
            throw new ContainerException(sprintf("Unable to create object '%s'", $class), 0, $e);
        }
        $arguments = [];
        if (($constructor = $reflection->getConstructor())) {
            $arguments = array_map(fn($parameter) => $this->argument($parameter, $constants), $constructor->getParameters());
        }
        return fn(Container $container) => new $class(... array_map(fn($arg) => $arg[0] ? $container->get($arg[1]) : $arg[1], $arguments));
    }
    /**
     * Create a callback for the given callable.
     *
     * @param callable $callback The callable.
     * @param array $constants Parameters as an associative array to pass to the callable (default: []).
     * @return callable The created callback.
     */
    private function callback(callable $callback, array $constants = []): callable
    {
        try {
            $reflection = new ReflectionFunction($callback);
        } catch (ReflectionException $ex) {
            throw new ContainerException("Unable to prepare factory", 0, $ex);
        }
        $parameters = $reflection->getParameters();
        if (empty($parameters)) {
            return $callback;
        }
        if (count($parameters) === 1 && self::isImplements($parameters[0]->getType(), self::class)) {
            return $callback;
        }
        $arguments = array_map(fn($parameter) => $this->argument($parameter, $constants), $parameters);
        return fn(Container $container) => $callback(... array_map(fn($arg) => $arg[0] ? $container->get($arg[1]): $arg[1], $arguments));
    }
    /**
     * Resolve the argument for a parameter.
     *
     * @param ReflectionParameter $parameter The parameter reflection.
     * @param array $constants Parameters as an associative array to pass to the callable or constructor.
     * @return array The resolved argument, consisting of a flag indicating if the argument is a dependency and the value.
     * @throws ContainerException If the argument cannot be resolved.
     */
    private function argument(ReflectionParameter $parameter, array $constants): ?array
    {
        $name = $parameter->getName();
        if (array_key_exists($name, $constants)) {
            return [false, $constants[$name]];
        } elseif (($type = $parameter->getType()) && !$type->isBuiltin() && ($this->has($typename = $type->getName()) || class_exists($typename))) {
            return [true, $typename];
        } elseif ($parameter->isDefaultValueAvailable()) {
            return [false, $parameter->getDefaultValue()];
        }
        throw new ContainerException(sprintf("Failed to process a parameter: '%s' of type '%s'", $name, $type));
    }
    
    private static function isImplements(string $type, string $class): bool
    {
        return $type == $class 
                || in_array($type, class_parents($class))
                || in_array($type, class_implements($class));
    }
}