atelierspierrot/library

View on GitHub
src/Library/Factory.php

Summary

Maintainability
F
4 days
Test Coverage
<?php
/**
 * This file is part of the Library package.
 *
 * Copyleft (ↄ) 2013-2016 Pierre Cassat <me@e-piwi.fr> and contributors
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 *
 * The source code of this package is available online at 
 * <http://github.com/atelierspierrot/library>.
 */

namespace Library;

use \Library\FactoryInterface;
use \Patterns\Abstracts\AbstractStaticCreator;
use \Library\Helper\Code as CodeHelper;
use \Library\Helper\Text as TextHelper;
use \ReflectionClass;

/**
 * Factory will try to create an object following user rules and passing it arguments
 *
 * ## Usage
 *
 *     $factory = \Library\Factory::create()
 *         // all methods are optional
 *         ->factoryName('A Name To Identify Error Message')
 *         ->mustImplement('RequiredInterface')
 *         ->mustImplementAll(array('RequiredInterface1', 'RequiredInterface2'))
 *         ->mustExtend('RequiredInheritance')
 *         ->mustImplementOrExtend(array('RequiredInheritance', 'OR', 'RequiredInterface'))
 *         ->defaultNamespace('\Possible\Namespace')
 *         ->mandatoryNamespace('\Required\Namespace')
 *         ->classNameMask(array('%s', '%s_Suffix'))
 *         ->callMethod('load')
 *         ;
 *
 * You can also define all options as an array to the creator:
 *
 *     $factory = \Library\Factory::create(array(
 *         // all options are optional
 *         'factory_name' => 'A Name To Identify Error Message',
 *         'must_implement' => 'RequiredInterface',
 *         'must_implement_all' => array('RequiredInterface1', 'RequiredInterface2'),
 *         'must_extend' => 'RequiredInheritance',
 *         'must_implement_or_extend' => array('RequiredInheritance', 'OR', 'RequiredInterface'),
 *         'default_namespace' => '\Possible\Namespace',
 *         'mandatory_namespace' => '\Required\Namespace',
 *         'class_name_mask' => array('%s', '%s_Suffix'),
 *         'call_method' => 'load',
 *     ));
 *
 * Then, to try to build the object:
 *
 *     $object = $factory->build($name, $params);
 *
 * Errors are thrown by default. You can avoid this using the `GRACEFULLY_FAILURE` constant flag.
 * All error messages are loaded in the `$logs` last parameter of the `build()` and
 * `findBuilder()` methods.
 *
 * ## Method parameters
 *
 * When the object creation method is called, the parameters passed to the `Factory::build()`
 * method are re-organized before to pass them to the method. This way, you can define the
 * parameters as an array using explicit indexes corresponding to the parameters names in
 * method declaration without working about their order.
 *
 * ## Specific method to build the instance
 *
 * In the case of a specific `$call_method` (not the classic `__construct`), the builder will
 * try to first construct the object except if the call method is static. If it is not static,
 * any existing constructor will be first called without parameters and then the defined
 * call method passing it the parameters.
 *
 * @author  piwi <me@e-piwi.fr>
 */
class Factory
    extends AbstractStaticCreator
    implements FactoryInterface
{

    /**
     * String added to error messages to identify the caller
     *
     * @var string A name to identify factory error messages
     * @use $factory->factoryName( $name )
     */
    protected $factory_name = '';

    /**
     * Current builder flag value
     *
     * @var int A class constant
     * @use $factory->flag( const )
     */
    protected $flag = null;

    /**
     * Method called on the object's builder class to create the instance
     *
     * Final object class MUST implement this method.
     *
     * If this is not the constructor but the class do have a public constructor, it will
     * be called first to create the instance.
     *
     * @var string Method to call for object construction
     * @use $factory->callMethod( $method )
     */
    protected $call_method = '__construct';

    /**
     * Array of masks used to construct the final class name using `printf()` method
     *
     * Final object class CAN be named following one of these masks.
     *
     * @var string Printf expression (%s will be replaced by the `$name` in CamelCase)
     * @use $factory->classNameMask( array( $maskX, $maskY ) ) or $factory->classNameMask( $maskX )
     */
    protected $class_name_mask = array('%s');

    /**
     * Array of possible optional namespaces used to search the class
     *
     * Final object class CAN be included in one of these namespaces.
     *
     * @var array
     * @use $factory->defaultNamespace( array( $nameX, $nameY ) ) or $factory->defaultNamespace( $nameX )
     */
    protected $default_namespace = array();

    /**
     * Array of possible namespaces the class MUST be included in
     *
     * Final object class MUST be included in one of these namespaces.
     *
     * @var array
     * @use $factory->mandatoryNamespace( array( $nameX, $nameY ) ) or $factory->mandatoryNamespace( $nameX )
     */
    protected $mandatory_namespace = array();

    /**
     * Array of possible interfaces the class MUST implement
     *
     * Final object class MUST implement one of these items.
     *
     * @var array
     * @use $factory->mustImplement( array( $nameX, $nameY ) ) or $factory->mustImplement( $nameX )
     */
    protected $must_implement = array();

    /**
     * Array of interfaces the class MUST implement
     *
     * Final object class MUST implement ALL these items.
     *
     * @var array
     * @use $factory->mustImplementAll( array( $nameX, $nameY ) )
     */
    protected $must_implement_all = array();

    /**
     * Array of possible classes the class MUST extend
     *
     * Final object class MUST extend one of these items.
     *
     * @var array
     * @use $factory->mustExtend( array( $nameX, $nameY ) ) or $factory->mustExtend( $nameX )
     */
    protected $must_extend = array();

    /**
     * Array of possible interfaces or classes the class MUST implement or extend
     *
     * Final object class MUST implement or extend one of these items.
     *
     * @var array
     * @use $factory->mustImplementOrExtend( array( $nameX, $nameY ) )
     */
    protected $must_implement_or_extend = array();

    /**
     * Initialize the factory with an array of options
     *
     * The options must be defined like `property => value`
     *
     * @param array $options
     *
     * @return void
     */
    public function init(array $options = null)
    {
        if (!empty($options)) {
            $this->setOptions($options);
        }
    }

    /**
     * Magic method to allow usage of `$factory->propertyInCamelCase()` for each class property
     *
     * @param string $name
     * @param array $arguments
     * @return self
     */
    public function __call($name, array $arguments)
    {
        $property_name = CodeHelper::getPropertyName($name);
        if (property_exists($this, $property_name)) {
            $param = array_shift($arguments);
            $this->setOptions(array(
                $property_name => is_array($this->{$property_name}) ? (
                    is_array($param) ? $param : array($param)
                ) : $param
            ));
        }
        return $this;
    }

    /**
     * Set the object options like `property => value`
     *
     * @param array $options
     * @return self
     */
    public function setOptions(array $options)
    {
        foreach ($options as $index=>$val) {
            if (property_exists($this, $index)) {
                $this->{$index} = $val;
            }
        }
        return $this;
    }

    /**
     * Build the object instance following current factory settings
     *
     * Errors are thrown by default but can be "gracefully" skipped using the flag `GRACEFULLY_FAILURE`.
     * In all cases, error messages are loaded in final parameter `$logs` passed by reference.
     *
     * @param string $name
     * @param array $parameters
     * @param int $flag One of the class constants flags
     * @param array $logs Passed by reference
     * @return object
     * @throws \RuntimeException if the class is not found
     * @throws \RuntimeException if the class doesn't implement or extend some required dependencies
     * @throws \RuntimeException if the class method for construction is not callable
     */
    public function build($name, array $parameters = null, $flag = self::ERROR_ON_FAILURE, array &$logs = array())
    {
        $this->flag($flag);
        $object = null;
        $builder_class_name = $this->findBuilder($name, $flag, $logs);

        if (!empty($builder_class_name)) {
            $reflection_obj = new ReflectionClass($builder_class_name);
            $is_static = ($reflection_obj->hasMethod($this->call_method) && $reflection_obj->getMethod($this->call_method)->isStatic());
            if (!$is_static || $this->call_method==='__construct') {
                if (
                    $reflection_obj->hasMethod('__construct') &&
                    $reflection_obj->getConstructor()->isPublic()
                ) {
                    if ($this->call_method==='__construct') {
                        $organized_parameters = CodeHelper::organizeArguments('__construct', $parameters, $builder_class_name);
                        $_caller = call_user_func_array(array($reflection_obj, 'newInstance'), $organized_parameters);
                    } else {
                        $_caller = call_user_func(array($reflection_obj, 'newInstance'));
                    }
                } else {
                    try {
                        if ($this->call_method==='__construct') {
                            $_caller = new $builder_class_name($parameters);
                        } else {
                            $_caller = new $builder_class_name;
                        }
                    } catch (\Exception $e) {
                        $logs[] = $this->_getErrorMessage('Constructor method for class "%s" is not callable!', $builder_class_name);
                        if ($flag & self::ERROR_ON_FAILURE) {
                            throw new \RuntimeException(end($logs));
                        }
                    }
                }
            }

            if (isset($_caller) && $this->call_method==='__construct') {
                $object = $_caller;
            } else {
                if (
                    $reflection_obj->hasMethod($this->call_method) &&
                    $reflection_obj->getMethod($this->call_method)->isPublic()
                ) {
                    if ($reflection_obj->getMethod($this->call_method)->isStatic()) {
                        $organized_parameters = CodeHelper::organizeArguments($this->call_method, $parameters, $builder_class_name);
                        $object = call_user_func_array(array($builder_class_name, $this->call_method), $organized_parameters);
                    } elseif (isset($_caller)) {
                        $organized_parameters = CodeHelper::organizeArguments($this->call_method, $parameters, $_caller);
                        $object = call_user_func_array(array($_caller, $this->call_method), $organized_parameters);
                    } else {
                        $logs[] = $this->_getErrorMessage('Error while trying to create "%s->%s" by factory builder!',
                            $builder_class_name, $this->call_method);
                        if ($flag & self::ERROR_ON_FAILURE) {
                            throw new \RuntimeException(end($logs));
                        }
                    }
                } else {
                    $logs[] = $this->_getErrorMessage('Method "%s" for factory building of class "%s" is not callable!',
                        $this->call_method, $builder_class_name);
                    if ($flag & self::ERROR_ON_FAILURE) {
                        throw new \RuntimeException(end($logs));
                    }
                }
            }

        } else {
            $logs[] = $this->_getErrorMessage('No matching class found for factory build "%s"!', $name);
            if ($flag & self::ERROR_ON_FAILURE) {
                throw new \RuntimeException(end($logs));
            }
        }

        return $object;
    }

    /**
     * Find the object builder class following current factory settings
     *
     * Errors are thrown by default but can be "gracefully" skipped using the flag `GRACEFULLY_FAILURE`.
     * In all cases, error messages are loaded in final parameter `$logs` passed by reference.
     *
     * @param string $name
     * @param int $flag One of the class constants flags
     * @param array $logs Passed by reference
     * @return null|string
     * @throws \RuntimeException if the class is not found
     * @throws \RuntimeException if the class doesn't implement or extend some required dependencies
     * @throws \RuntimeException if the class method for construction is not callable
     */
    public function findBuilder($name, $flag = self::ERROR_ON_FAILURE, array &$logs = array())
    {
        $this->flag($flag);
        $cc_name = array(TextHelper::toCamelCase($name));
        if (!$this->_findClasses($cc_name)) {
            $cc_name = $this->_buildClassesNames($cc_name, $this->class_name_mask);
        }

        if (!$this->_findClasses($cc_name)) {
            $namespaces = array();
            if (!empty($this->default_namespace)) {
                $namespaces = array_merge($namespaces, $this->default_namespace);
            }
            if (!empty($this->mandatory_namespace)) {
                $namespaces = array_merge($namespaces, $this->mandatory_namespace);
            }
            if (!empty($namespaces)) {
                $cc_name = $this->_addNamespaces($cc_name, $namespaces);
            }
        }

        if (false!==$_cls = $this->_findClasses($cc_name)) {
        
            // required namespace
            if (!empty($this->mandatory_namespace) && !$this->_classesInNamespaces($_cls, $this->mandatory_namespace)) {
                $logs[] = $this->_getErrorMessage(
                    count($this->mandatory_namespace)>1 ? 'Class "%s" must be included in one of the following namespaces "%s"!' : 'Class "%s" must be in namespace "%s"!',
                    $_cls, implode('", "', $this->mandatory_namespace));
                if ($flag & self::ERROR_ON_FAILURE) {
                    throw new \RuntimeException(end($logs));
                }
                return null;
            }

            // required interface
            if (!empty($this->must_implement) && !$this->_classesImplements($_cls, $this->must_implement, false, $logs)) {
                $logs[] = $this->_getErrorMessage(
                    count($this->must_implement)>1 ? 'Class "%s" must implement one of the following interfaces "%s"!' : 'Class "%s" must implement interface "%s"!',
                    $_cls, implode('", "', $this->must_implement));
                if ($flag & self::ERROR_ON_FAILURE) {
                    throw new \RuntimeException(end($logs));
                }
                return null;
            }

            // required interfaces
            if (!empty($this->must_implement_all) && !$this->_classesImplements($_cls, $this->must_implement_all, true, $logs)) {
                $logs[] = $this->_getErrorMessage(
                    count($this->must_implement_all)>1 ? 'Class "%s" must implement the following interfaces "%s"!' : 'Class "%s" must implement interface "%s"!',
                    $_cls, implode('", "', $this->must_implement_all));
                if ($flag & self::ERROR_ON_FAILURE) {
                    throw new \RuntimeException(end($logs));
                }
                return null;
            }

            // required inheritance
            if (!empty($this->must_extend) && !$this->_classesExtends($_cls, $this->must_extend, $logs)) {
                $logs[] = $this->_getErrorMessage(
                    count($this->must_extend)>1 ? 'Class "%s" must extend one of the following classes "%s"!' : 'Class "%s" must extend class "%s"!',
                    $_cls, implode('", "', $this->must_extend));
                if ($flag & self::ERROR_ON_FAILURE) {
                    throw new \RuntimeException(end($logs));
                }
                return null;
            }

            // required interface OR inheritance
            if (!empty($this->must_implement_or_extend) &&
                !$this->_classesImplements($_cls, $this->must_implement_or_extend) &&
                !$this->_classesExtends($_cls, $this->must_implement_or_extend)
            ) {
                $logs[] = $this->_getErrorMessage('Class "%s" doesn\'t implement or extend the following required interfaces or classes "%s"!', 
                            $_cls, implode('", "', $this->must_implement_or_extend));
                if ($flag & self::ERROR_ON_FAILURE) {
                    throw new \RuntimeException(end($logs));
                }
                return null;
            }

            return $_cls;
        }
        return null;
    }

// -----------------------
// Processes
// -----------------------

    /**
     * Build the class name filling the `$class_name_mask`
     *
     * @param string|array $class_names
     * @return mixed The found class name if it exists, false otherwise
     */
    protected function _findClasses($class_names)
    {
        if (!is_array($class_names)) {
            $class_names = array($class_names);
        }
        foreach ($class_names as $_cls) {
            if (true===class_exists($_cls)) {
                return $_cls;
            }
        }
        return false;
    }

    /**
     * Build the class name filling a set of masks
     *
     * @param string|array $names
     * @param array $masks
     * @return array
     */
    protected function _buildClassesNames($names, array $masks)
    {
        if (!is_array($names)) {
            $names = array($names);
        }
        $return_names = array();
        foreach ($names as $_name) {
            foreach ($masks as $_mask) {
                $return_names[] = sprintf($_mask, TextHelper::toCamelCase($_name));
            }
        }
        return $return_names;
    }

    /**
     * Add a set of namespaces to a list of class names
     *
     * @param string|array $names
     * @param array $namespaces
     * @param array $logs Passed by reference
     * @return array
     */
    protected function _addNamespaces($names, array $namespaces, array &$logs = array())
    {
        if (!is_array($names)) {
            $names = array($names);
        }
        $return_names = array();
        foreach ($names as $_name) {
            foreach ($namespaces as $_namespace) {
                if (CodeHelper::namespaceExists($_namespace)) {
                    $tmp_namespace = rtrim(TextHelper::toCamelCase($_namespace), '\\').'\\';
                    $return_names[] = $tmp_namespace.str_replace($tmp_namespace, '', TextHelper::toCamelCase($_name));
                } else {
                    $logs[] = $this->_getErrorMessage('Namespace "%s" not found!', $_namespace);
                }
            }
        }
        return $return_names;
    }

    /**
     * Test if a set of class names implements a list of interfaces
     *
     * @param string|array $names
     * @param array $interfaces
     * @param bool $must_implement_all
     * @param array $logs Passed by reference
     * @return bool
     */
    protected function _classesImplements($names, array $interfaces, $must_implement_all = false, array &$logs = array())
    {
        if (!is_array($names)) {
            $names = array($names);
        }
        $ok = false;
        foreach ($names as $_name) {
            foreach ($interfaces as $_interface) {
                if (interface_exists($_interface)) {
                    if (CodeHelper::impelementsInterface($_name, $_interface)) {
                        $ok = true;
                    } elseif ($must_implement_all) {
                        $ok = false;
                    }
                } else {
                    $logs[] = $this->_getErrorMessage('Interface "%s" not found!', $_interface);
                }
            }
        }
        return $ok;
    }

    /**
     * Test if a set of class names extends a list of classes
     *
     * @param string|array $names
     * @param array $classes
     * @param array $logs Passed by reference
     * @return bool
     */
    protected function _classesExtends($names, array $classes, array &$logs = array())
    {
        if (!is_array($names)) {
            $names = array($names);
        }
        foreach ($names as $_name) {
            foreach ($classes as $_class) {
                if (class_exists($_class)) {
                    if (CodeHelper::extendsClass($_name, $_class)) {
                        return true;
                    }
                } else {
                    $logs[] = $this->_getErrorMessage('Class "%s" not found!', $_class);
                }
            }
        }
        return false;
    }

    /**
     * Test if a classes names set is in a set of namespaces
     *
     * @param string|array $names
     * @param array $namespaces
     * @param array $logs Passed by reference
     * @return string|bool
     */
    protected function _classesInNamespaces($names, array $namespaces, array &$logs = array())
    {
        if (!is_array($names)) {
            $names = array($names);
        }
        foreach ($names as $_name) {
            foreach ($namespaces as $_namespace) {
                if (CodeHelper::namespaceExists($_namespace)) {
                    $tmp_namespace = rtrim(TextHelper::toCamelCase($_namespace), '\\').'\\';
                    if (substr_count(TextHelper::toCamelCase($_name), $tmp_namespace)>0) {
                        return $_name;
                    }
                } else {
                    $logs[] = $this->_getErrorMessage('Namespace "%s" not found!', $_namespace);
                }
            }
        }
        return false;
    }

    /**
     * Build a factory error message adding it the `$factory_name` if so
     *
     * @return string
     */
    protected function _getErrorMessage()
    {
        return (!empty($this->factory_name) ? '['.$this->factory_name.'] ' : '')
            .call_user_func_array('sprintf', func_get_args());
    }

}