Extra
5 years ago
Loader
5 years ago
tests
2 years ago
.gitignore
3 years ago
Dice.php
2 years ago
README.md
5 years ago
composer.json
5 years ago
phpunit.xml
5 years ago
Dice.php
279 lines
| 1 | <?php |
| 2 | /* @description Dice - A minimal Dependency Injection Container for PHP |
| 3 | * @author Tom Butler tom@r.je |
| 4 | * @copyright 2012-2020 Tom Butler <tom@r.je> | https://r.je/dice |
| 5 | * @license http://www.opensource.org/licenses/bsd-license.php BSD License |
| 6 | * @version 4.0 */ |
| 7 | namespace PrestoPlayer\Dice; |
| 8 | class Dice { |
| 9 | const CONSTANT = 'Dice::CONSTANT'; |
| 10 | const GLOBAL = 'Dice::GLOBAL'; |
| 11 | const INSTANCE = 'Dice::INSTANCE'; |
| 12 | const CHAIN_CALL = 'Dice::CHAIN_CALL'; |
| 13 | const SELF = 'Dice::SELF'; |
| 14 | /** |
| 15 | * @var array $rules Rules which have been set using addRule() |
| 16 | */ |
| 17 | private $rules = []; |
| 18 | |
| 19 | /** |
| 20 | * @var array $cache A cache of closures based on class name so each class is only reflected once |
| 21 | */ |
| 22 | private $cache = []; |
| 23 | |
| 24 | /** |
| 25 | * @var array $instances Stores any instances marked as 'shared' so create() can return the same instance |
| 26 | */ |
| 27 | private $instances = []; |
| 28 | |
| 29 | /** |
| 30 | * Add a rule $rule to the class $name |
| 31 | * @param string $name The name of the class to add the rule for |
| 32 | * @param array $rule The container can be fully configured using rules provided by associative arrays. See {@link https://r.je/dice.html#example3} for a description of the rules. |
| 33 | */ |
| 34 | public function addRule(string $name, array $rule): self { |
| 35 | $dice = clone $this; |
| 36 | $this->addRuleTo($dice, $name, $rule); |
| 37 | return $dice; |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * Add rules as array. Useful for JSON loading $dice->addRules(json_decode(file_get_contents('foo.json')); |
| 42 | * @param array Rules in a single array [name => $rule] format |
| 43 | */ |
| 44 | public function addRules($rules): self { |
| 45 | if (is_string($rules)) $rules = json_decode(file_get_contents($rules), true); |
| 46 | $dice = clone $this; |
| 47 | foreach ($rules as $name => $rule) $this->addRuleTo($dice,$name, $rule); |
| 48 | return $dice; |
| 49 | } |
| 50 | |
| 51 | private function addRuleTo(Dice $dice, string $name, array $rule) { |
| 52 | if (isset($rule['instanceOf']) && (!array_key_exists('inherit', $rule) || $rule['inherit'] === true )) |
| 53 | $rule = array_replace_recursive($dice->getRule($rule['instanceOf']), $rule); |
| 54 | //Allow substitutions rules to be defined with a leading a slash |
| 55 | if (isset($rule['substitutions'])) foreach($rule['substitutions'] as $key => $value) $rule['substitutions'][ltrim($key, '\\')] = $value; |
| 56 | //Clear any existing instance or cache for this class |
| 57 | unset($dice->instances[$name], $dice->cache[$name]); |
| 58 | $dice->rules[ltrim(strtolower($name), '\\')] = array_replace_recursive($dice->getRule($name), $rule); |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * Returns the rule that will be applied to the class $name when calling create() |
| 63 | * @param string name The name of the class to get the rules for |
| 64 | * @return array The rules for the specified class |
| 65 | */ |
| 66 | public function getRule(string $name): array { |
| 67 | $lcName = strtolower(ltrim($name, '\\')); |
| 68 | if (isset($this->rules[$lcName])) return $this->rules[$lcName]; |
| 69 | |
| 70 | foreach ($this->rules as $key => $rule) { // Find a rule which matches the class described in $name where: |
| 71 | if (empty($rule['instanceOf']) // It's not a named instance, the rule is applied to a class name |
| 72 | && $key !== '*' // It's not the default rule |
| 73 | && is_subclass_of($name, $key) // The rule is applied to a parent class |
| 74 | && (!array_key_exists('inherit', $rule) || $rule['inherit'] === true )) // And that rule should be inherited to subclasses |
| 75 | return $rule; |
| 76 | } |
| 77 | // No rule has matched, return the default rule if it's set |
| 78 | return isset($this->rules['*']) ? $this->rules['*'] : []; |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Returns a fully constructed object based on $name using $args and $share as constructor arguments if supplied |
| 83 | * @param string name The name of the class to instantiate |
| 84 | * @param array $args An array with any additional arguments to be passed into the constructor upon instantiation |
| 85 | * @param array $share a list of defined in shareInstances for objects higher up the object graph, should only be used internally |
| 86 | * @return object A fully constructed object based on the specified input arguments |
| 87 | */ |
| 88 | public function create(string $name, array $args = [], array $share = []) { |
| 89 | // Is there a shared instance set? Return it. Better here than a closure for this, calling a closure is slower. |
| 90 | if (!empty($this->instances[$name])) return $this->instances[$name]; |
| 91 | |
| 92 | // Create a closure for creating the object if there isn't one already |
| 93 | if (empty($this->cache[$name])) $this->cache[$name] = $this->getClosure(ltrim($name, '\\'), $this->getRule($name)); |
| 94 | |
| 95 | // Call the cached closure which will return a fully constructed object of type $name |
| 96 | return $this->cache[$name]($args, $share); |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Returns a closure for creating object $name based on $rule, caching the reflection object for later use |
| 101 | * @param string $name the Name of the class to get the closure for |
| 102 | * @param array $rule The container can be fully configured using rules provided by associative arrays. See {@link https://r.je/dice.html#example3} for a description of the rules. |
| 103 | * @return callable A closure |
| 104 | */ |
| 105 | private function getClosure(string $name, array $rule) { |
| 106 | // Reflect the class and constructor, this should only ever be done once per class and get cached |
| 107 | $class = new \ReflectionClass(isset($rule['instanceOf']) ? $rule['instanceOf'] : $name); |
| 108 | $constructor = $class->getConstructor(); |
| 109 | |
| 110 | // Create parameter generating function in order to cache reflection on the parameters. This way $reflect->getParameters() only ever gets called once |
| 111 | $params = $constructor ? $this->getParams($constructor, $rule) : null; |
| 112 | //PHP throws a fatal error rather than an exception when trying to instantiate an interface, detect it and throw an exception instead |
| 113 | if ($class->isInterface()) $closure = function() { |
| 114 | throw new \InvalidArgumentException('Cannot instantiate interface'); |
| 115 | }; |
| 116 | // Get a closure based on the type of object being created: Shared, normal or constructorless |
| 117 | else if ($params) $closure = function (array $args, array $share) use ($class, $params) { |
| 118 | // This class has depenencies, call the $params closure to generate them based on $args and $share |
| 119 | return new $class->name(...$params($args, $share)); |
| 120 | }; |
| 121 | else $closure = function () use ($class) { // No constructor arguments, just instantiate the class |
| 122 | return new $class->name; |
| 123 | }; |
| 124 | |
| 125 | if (!empty($rule['shared'])) $closure = function (array $args, array $share) use ($class, $name, $constructor, $params, $closure) { |
| 126 | //Internal classes may not be able to be constructed without calling the constructor and will not suffer from #7, construct them normally. |
| 127 | if ($class->isInternal()) $this->instances[$class->name] = $this->instances['\\' . $class->name] = $closure($args, $share); |
| 128 | else { |
| 129 | //Otherwise, create the class without calling the constructor (and write to \$name and $name, see issue #68) |
| 130 | $this->instances[$name] = $this->instances['\\' . $name] = $class->newInstanceWithoutConstructor(); |
| 131 | // Now call this constructor after constructing all the dependencies. This avoids problems with cyclic references (issue #7) |
| 132 | if ($constructor) $constructor->invokeArgs($this->instances[$name], $params($args, $share)); |
| 133 | } |
| 134 | return $this->instances[$name]; |
| 135 | }; |
| 136 | // If there are shared instances, create them and merge them with shared instances higher up the object graph |
| 137 | if (isset($rule['shareInstances'])) $closure = function(array $args, array $share) use ($closure, $rule) { |
| 138 | foreach($rule['shareInstances'] as $instance) $share[] = $this->create($instance, [], $share); |
| 139 | return $closure($args, $share); |
| 140 | }; |
| 141 | // When $rule['call'] is set, wrap the closure in another closure which will call the required methods after constructing the object |
| 142 | // By putting this in a closure, the loop is never executed unless call is actually set |
| 143 | return isset($rule['call']) ? function (array $args, array $share) use ($closure, $class, $rule, $name) { |
| 144 | // Construct the object using the original closure |
| 145 | $object = $closure($args, $share); |
| 146 | |
| 147 | foreach ($rule['call'] as $call) { |
| 148 | // Generate the method arguments using getParams() and call the returned closure |
| 149 | $params = $this->getParams($class->getMethod($call[0]), ['shareInstances' => isset($rule['shareInstances']) ? $rule['shareInstances'] : [] ])(($this->expand(isset($call[1]) ? $call[1] : [])), $share); |
| 150 | $return = $object->{$call[0]}(...$params); |
| 151 | if (isset($call[2])) { |
| 152 | if ($call[2] === self::CHAIN_CALL) { |
| 153 | if (!empty($rule['shared'])) $this->instances[$name] = $return; |
| 154 | if (is_object($return)) $class = new \ReflectionClass(get_class($return)); |
| 155 | $object = $return; |
| 156 | } |
| 157 | else if (is_callable($call[2])) call_user_func($call[2], $return); |
| 158 | } |
| 159 | } |
| 160 | return $object; |
| 161 | } : $closure; |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Looks for Dice::INSTANCE, Dice::GLOBAL or Dice::CONSTANT array keys in $param and when found returns an object based on the value see {@link https:// r.je/dice.html#example3-1} |
| 166 | * @param mixed $param Either a string or an array, |
| 167 | * @param array $share Array of instances from 'shareInstances', required for calls to `create` |
| 168 | * @param bool $createFromString |
| 169 | * @return mixed |
| 170 | */ |
| 171 | private function expand($param, array $share = [], bool $createFromString = false) { |
| 172 | if (is_array($param)) { |
| 173 | //if a rule specifies Dice::INSTANCE, look up the relevant instance |
| 174 | if (isset($param[self::INSTANCE])) { |
| 175 | if ($param[self::INSTANCE] === self::SELF) return $this; |
| 176 | //Check for 'params' which allows parameters to be sent to the instance when it's created |
| 177 | //Either as a callback method or to the constructor of the instance |
| 178 | $args = isset($param['params']) ? $this->expand($param['params']) : []; |
| 179 | |
| 180 | //Support Dice::INSTANCE by creating/fetching the specified instance |
| 181 | if (is_array($param[self::INSTANCE])) $param[self::INSTANCE][0] = $this->expand($param[self::INSTANCE][0], $share, true); |
| 182 | if (is_callable($param[self::INSTANCE])) return call_user_func($param[self::INSTANCE], ...$args); |
| 183 | else return $this->create($param[self::INSTANCE], array_merge($args, $share)); |
| 184 | } |
| 185 | else if (isset($param[self::GLOBAL])) return $GLOBALS[$param[self::GLOBAL]]; |
| 186 | else if (isset($param[self::CONSTANT])) return constant($param[self::CONSTANT]); |
| 187 | else foreach ($param as $name => $value) $param[$name] = $this->expand($value, $share); |
| 188 | } |
| 189 | |
| 190 | return is_string($param) && $createFromString ? $this->create($param) : $param; |
| 191 | } |
| 192 | /** |
| 193 | * Looks through the array $search for any object which can be used to fulfil $param |
| 194 | The original array $search is modifed so must be passed by reference. |
| 195 | |
| 196 | */ |
| 197 | private function matchParam(\ReflectionParameter $param, $class, array &$search) { |
| 198 | foreach ($search as $i => $arg) { |
| 199 | if ($class && ($arg instanceof $class || ($arg === null && $param->allowsNull()))) { |
| 200 | // The argument matched, return it and remove it from $search so it won't wrongly match another parameter |
| 201 | return array_splice($search, $i, 1)[0]; |
| 202 | } |
| 203 | } |
| 204 | return false; |
| 205 | } |
| 206 | /** |
| 207 | * Returns a closure that generates arguments for $method based on $rule and any $args passed into the closure |
| 208 | * @param object $method An instance of ReflectionMethod (see: {@link http:// php.net/manual/en/class.reflectionmethod.php}) |
| 209 | * @param array $rule The container can be fully configured using rules provided by associative arrays. See {@link https://r.je/dice.html#example3} for a description of the rules. |
| 210 | * @return callable A closure that uses the cached information to generate the arguments for the method |
| 211 | */ |
| 212 | private function getParams(\ReflectionMethod $method, array $rule) { |
| 213 | // Cache some information about the parameter in $paramInfo so (slow) reflection isn't needed every time |
| 214 | $paramInfo = []; |
| 215 | foreach ($method->getParameters() as $param) { |
| 216 | $type = $param->getType(); |
| 217 | |
| 218 | $class = $type instanceof \ReflectionNamedType && !$type->isBuiltIn() ? $type->getName() : null; |
| 219 | |
| 220 | $paramInfo[] = [$class, $param, isset($rule['substitutions']) && array_key_exists($class, $rule['substitutions'])]; |
| 221 | } |
| 222 | |
| 223 | // Return a closure that uses the cached information to generate the arguments for the method |
| 224 | return function (array $args, array $share = []) use ($paramInfo, $rule) { |
| 225 | // If the rule has construtParams set, construct any classes reference and use them as $args |
| 226 | if (isset($rule['constructParams'])) $args = array_merge($args, $this->expand($rule['constructParams'], $share)); |
| 227 | |
| 228 | // Array of matched parameters |
| 229 | $parameters = []; |
| 230 | |
| 231 | // Fnd a value for each method argument |
| 232 | foreach ($paramInfo as list($class, $param, $sub)) { |
| 233 | // Loop through $args and see whether or not each value can match the current parameter based on type hint |
| 234 | if ($args && ($match = $this->matchParam($param, $class, $args)) !== false) { |
| 235 | $parameters[] = $match; |
| 236 | } |
| 237 | // Do the same with $share |
| 238 | else if (($copy = $share) && ($match = $this->matchParam($param, $class, $copy)) !== false) { |
| 239 | $parameters[] = $match; |
| 240 | } |
| 241 | // When nothing from $args or $share matches but a class is type hinted, create an instance to use, using a substitution if set |
| 242 | else if ($class) try { |
| 243 | if ($sub) { |
| 244 | $parameters[] = $this->expand($rule['substitutions'][$class], $share, true); |
| 245 | } |
| 246 | else { |
| 247 | $parameters[] = !$param->allowsNull() ? $this->create($class, [], $share) : null; |
| 248 | } |
| 249 | } |
| 250 | catch (\InvalidArgumentException $e) { |
| 251 | } |
| 252 | // Support PHP 7 scalar type hinting, is_a('string', 'foo') doesn't work so this is a hacky AF workaround: call_user_func('is_' . $type, '') |
| 253 | |
| 254 | //Find a match in $args for scalar types |
| 255 | else if ($args && $param->getType()) { |
| 256 | for ($i = 0; $i < count($args); $i++) { |
| 257 | if (call_user_func('is_' . $param->getType()->getName(), $args[$i])) { |
| 258 | $parameters[] = array_splice($args, $i, 1)[0]; |
| 259 | break; |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | else if ($args) { |
| 264 | $parameters[] = $this->expand(array_shift($args)); |
| 265 | } |
| 266 | // For variadic parameters, provide remaining $args |
| 267 | else if ($param->isVariadic()) { |
| 268 | $parameters = array_merge($parameters, $args); |
| 269 | } |
| 270 | // There's no type hint and nothing left in $args, provide the default value or null |
| 271 | else { |
| 272 | $parameters[] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; |
| 273 | } |
| 274 | } |
| 275 | return $parameters; |
| 276 | }; |
| 277 | } |
| 278 | } |
| 279 |