src/Factory.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

declare(strict_types=1);

namespace Atk4\Core;

class Factory
{
    use WarnDynamicPropertyTrait;

    private static ?Factory $_instance = null;

    protected function __construct()
    {
        // singleton
    }

    final protected static function getInstance(): self
    {
        if (self::$_instance === null) {
            self::$_instance = new self();
        }

        return self::$_instance;
    }

    /**
     * @param array<mixed>|object|null ...$seeds
     *
     * @return ($seeds is object ? object : array<mixed>)
     */
    protected function _mergeSeeds(...$seeds)
    {
        // merge seeds but prefer seed over seed2
        // move numerical keys to the beginning and sort them
        $arguments = [];
        $injection = [];
        $obj = null;
        $beforeObjKeys = null;
        foreach ($seeds as $seedIndex => $seed) {
            if (is_object($seed)) {
                if ($obj !== null) {
                    throw new Exception('Two or more objects specified as seed');
                }

                $obj = $seed;
                if (count($injection) > 0) {
                    $beforeObjKeys = array_flip(array_keys($injection));
                }

                continue;
            } elseif ($seed === null) {
                continue;
            }

            // check seed
            if (!array_key_exists(0, $seed)) {
                // allow this method to be used to merge seeds without class name
            } elseif ($seed[0] === null) {
                // pass
            } elseif (!is_string($seed[0])) {
                throw new Exception('Seed class type (' . get_debug_type($seed[0]) . ') must be string');
            } /*elseif (!class_exists($seed[0])) {
                throw new Exception('Seed class "' . $seed[0] . '" not found');
            }*/

            foreach ($seed as $k => $v) {
                if (is_int($k)) {
                    if (!isset($arguments[$k])) {
                        $arguments[$k] = $v;
                    }
                } elseif ($v !== null) {
                    if (!isset($injection[$k])) {
                        $injection[$k] = $v;
                    }
                }
            }
        }

        ksort($arguments, \SORT_NUMERIC);
        if ($obj === null) {
            $arguments += $injection;

            return $arguments;
        }

        unset($arguments[0]); // the first argument specifies a class name
        if (count($arguments) > 0) {
            throw (new Exception('Constructor arguments cannot be injected into existing object'))
                ->addMoreInfo('object', $obj)
                ->addMoreInfo('arguments', $arguments);
        }

        if (count($injection) > 0) {
            if (!TraitUtil::hasDiContainerTrait($obj)) {
                throw (new Exception('Property injection is possible only to objects that use Atk4\Core\DiContainerTrait trait'))
                    ->addMoreInfo('object', $obj)
                    ->addMoreInfo('injection', $injection);
            }

            if ($beforeObjKeys !== null) {
                $injectionActive = array_intersect_key($injection, $beforeObjKeys);
                $injection = array_diff_key($injection, $beforeObjKeys);

                $obj->setDefaults($injectionActive, false);
            }
            $obj->setDefaults($injection, true);
        }

        return $obj;
    }

    /**
     * @param class-string $className
     * @param list<mixed>  $ctorArgs
     */
    protected function _newObject(string $className, array $ctorArgs): object
    {
        return new $className(...$ctorArgs);
    }

    /**
     * @param array<mixed>|object $seed
     * @param array<mixed>        $defaults
     */
    protected function _factory($seed, array $defaults): object
    {
        if (!is_array($seed) && !is_object($seed)) { // @phpstan-ignore function.alreadyNarrowedType, booleanAnd.alwaysFalse
            throw new Exception('Use of non-array (' . gettype($seed) . ') seed is not supported');
        }

        array_unshift($defaults, null); // insert argument 0

        if (is_object($seed)) {
            $defaults = $this->_mergeSeeds([], $defaults);
            $defaults[0] = $seed;
            $seed = $defaults;
        } else {
            $seed = $this->_mergeSeeds($seed, $defaults);
        }
        unset($defaults);

        $arguments = array_filter($seed, 'is_int', \ARRAY_FILTER_USE_KEY); // with numeric keys
        $injection = array_diff_key($seed, $arguments); // with string keys
        $object = array_shift($arguments); // first numeric key argument is object

        if (!is_object($object)) {
            if (!is_string($object)) {
                throw (new Exception('Class name is not specified by the seed'))
                    ->addMoreInfo('seed', $seed);
            }

            $object = $this->_newObject($object, $arguments);
        }

        if (count($injection) > 0) {
            $this->_mergeSeeds($injection, $object);
        }

        return $object;
    }

    /**
     * Given two seeds (or more) will merge them, prioritizing the first argument.
     * If object is passed on either of arguments, then it will setDefaults() remaining
     * arguments, respecting their positioning.
     *
     * To learn more about mechanics of factory trait, see documentation
     *
     * @param array<mixed>|object|null ...$seeds
     *
     * @return ($seeds is object ? object : array<mixed>) if one seed is an object, that object is returned
     */
    final public static function mergeSeeds(...$seeds)
    {
        return self::getInstance()->_mergeSeeds(...$seeds);
    }

    /**
     * Given a Seed (see doc) as a first argument, will create object of a corresponding
     * class, call constructor with numerical arguments of a seed and inject key/value
     * arguments.
     *
     * Argument $defaults has the same effect as the seed, but will not contain the class.
     * Class is always determined by seed, except if you pass object into defaults.
     *
     * To learn more about mechanics of factory trait, see documentation
     *
     * @param array<mixed>|object $seed
     * @param array<mixed>        $defaults
     */
    final public static function factory($seed, $defaults = []): object
    {
        if ('func_num_args'() > 2) { // prevent bad usage
            throw new \Error('Too many method arguments');
        }

        if ($defaults === null) { // @phpstan-ignore identical.alwaysFalse (should be deprecated soon)
            $defaults = [];
        }

        return self::getInstance()->_factory($seed, $defaults);
    }
}