Completed
Push — master ( 2ad2bf...464443 )
by Garrett
01:35
created

Dice::addRules()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 3
nc 2
nop 1
1
<?php
2
3
/**
4
 * @description Dice - A minimal Dependency Injection Container for PHP
5
 *
6
 * @author      Tom Butler [email protected]
7
 * @author      Garrett Whitehorn http://garrettw.net/
8
 * @copyright   2012-2015 Tom Butler <[email protected]> | https://r.je/dice.html
9
 * @license     http://www.opensource.org/licenses/bsd-license.php  BSD License
10
 *
11
 * @version     2.0
12
 */
13
14
namespace Dice;
15
16
class Dice
17
{
18
    const CONSTANT = 'Dice::CONSTANT';
19
	const INSTANCE = 'Dice::INSTANCE';
20
21
    /**
22
     * @var array $rules Rules which have been set using addRule()
23
     */
24
    private $rules = [];
25
26
    /**
27
     * @var array $cache A cache of closures based on class name so each class is only reflected once
28
     */
29
    private $cache = [];
30
31
    /**
32
     * @var array $instances Stores any instances marked as 'shared' so create() can return the same instance
33
     */
34
    private $instances = [];
35
36
    /**
37
     * Constructor which allows setting a default ruleset to apply to all objects.
38
     */
39
    public function __construct($defaultRule = [])
40
    {
41
        if (!empty($defaultRule)) {
42
            $this->rules['*'] = $defaultRule;
43
        }
44
    }
45
46
    /**
47
     * Adds a rule $rule to the class $classname.
48
     *
49
     * The container can be fully configured using rules provided by associative arrays.
50
     * See {@link https://r.je/dice.html#example3} for a description of the rules.
51
     *
52
     * @param string $classname The name of the class to add the rule for
53
     * @param array $rule The rule to add to it
54
     */
55
    public function addRule($classname, $rule)
56
    {
57
        if (isset($rule['instanceOf'])
58
            && \is_string($rule['instanceOf'])
59
            && (!\array_key_exists('inherit', $rule) || $rule['inherit'] === true)
60
        ) {
61
            $rule = \array_replace_recursive($this->getRule($rule['instanceOf']), $rule);
62
        }
63
        $this->rules[self::normalizeName($classname)] = \array_replace_recursive($this->getRule($classname), $rule);
64
    }
65
66
    /**
67
     * Add rules as array. Useful for JSON loading
68
     * $dice->addRules(json_decode(file_get_contents('foo.json'));
69
     *
70
     * @param array Rules in a single array [name => $rule] format
71
     */
72
    public function addRules(array $rules)
73
    {
74
        foreach ($rules as $name => $rule) {
75
            $this->addRule($name, $rule);
76
        }
77
    }
78
79
    /**
80
     * Returns the rule that will be applied to the class $matching during create().
81
     *
82
     * @param string $name The name of the ruleset to get - can be a class or not
83
     * @return array Ruleset that applies when instantiating the given name
84
     */
85
    public function getRule($name)
86
    {
87
        // first, check for exact match
88
        $normalname = self::normalizeName($name);
89
90
        if (isset($this->rules[$normalname])) {
91
            return $this->rules[$normalname];
92
        }
93
94
        // next, look for a rule where:
95
        foreach ($this->rules as $key => $rule) {
96
            if ($key !== '*'                    // it's not the default rule,
97
                && \is_subclass_of($name, $key) // its name is a parent class of what we're looking for,
98
                && empty($rule['instanceOf'])   // it's not a named instance,
99
                && (!array_key_exists('inherit', $rule) || $rule['inherit'] === true) // and it applies to subclasses
100
            ) {
101
                return $rule;
102
            }
103
        }
104
105
        // if we get here, return the default rule if it's set
106
        return (isset($this->rules['*'])) ? $this->rules['*'] : [];
107
    }
108
109
    /**
110
     * Returns a fully constructed object based on $classname using $args and $share as constructor arguments
111
     *
112
     * @param string $classname The name of the class to instantiate
113
     * @param array $args An array with any additional arguments to be passed into the constructor
114
     * @param array $share Whether the same class instance should be passed around each time
115
     * @return object A fully constructed object based on the specified input arguments
116
     */
117
    public function create($classname, array $args = [], array $share = [])
118
    {
119
        if (!empty($this->instances[$classname])) {
120
            // we've already created a shared instance so return it to save the closure call.
121
            return $this->instances[$classname];
122
        }
123
124
        // so now, we either need a new instance or just don't have one stored
125
        // but if we have the closure stored that creates it, call that
126
        if (!empty($this->cache[$classname])) {
127
            return $this->cache[$classname]($args, $share);
128
        }
129
130
        $rule = $this->getRule($classname);
131
        // Reflect the class for inspection, this should only ever be done once per class and then be cached
132
        $class = new \ReflectionClass(isset($rule['instanceOf']) ? $rule['instanceOf'] : $classname);
133
        $closure = $this->getClosure($classname, $rule, $class);
134
135
        //If there are shared instances, create them and merge them with shared instances higher up the object graph
136
        if (isset($rule['shareInstances'])) {
137
            $closure = function(array $args, array $share) use ($closure, $rule) {
138
                return $closure($args, array_merge($args, $share, array_map([$this, 'create'], $rule['shareInstances'])));
139
            };
140
        }
141
142
        // When $rule['call'] is set, wrap the closure in another closure which calls the required methods after constructing the object.
143
        // By putting this in a closure, the loop is never executed unless call is actually set.
144
        if (isset($rule['call'])) {
145
            $closure = function(array $args, array $share) use ($closure, $class, $rule) {
146
                // Construct the object using the original closure
147
                $object = $closure($args, $share);
148
149
                foreach ($rule['call'] as $call) {
150
                    // Generate the method arguments using getParams() and call the returned closure
151
                    // (in php7 it will be ()() rather than __invoke)
152
                    $shareRule = ['shareInstances' => isset($rule['shareInstances']) ? $rule['shareInstances'] : []];
153
                    $callMeMaybe = isset($call[1]) ? $call[1] : [];
154
                    $return = call_user_func_array(
155
                        [$object, $call[0]],
156
                        $this->getParams($class->getMethod($call[0]), $shareRule)
157
                            ->__invoke($this->expand($callMeMaybe))
158
                    );
159
160
                    if (isset($call[2]) && is_callable($call[2])) {
161
                        call_user_func($call[2], $return);
162
                    }
163
                }
164
165
                return $object;
166
            };
167
        }
168
169
        $this->cache[$classname] = $closure;
170
171
        return $this->cache[$classname]($args, $share);
172
    }
173
174
    /**
175
     * Returns a closure for creating object $name based on $rule, caching the reflection object for later use.
176
     *
177
     * The container can be fully configured using rules provided by associative arrays.
178
     * See {@link https://r.je/dice.html#example3} for a description of the rules.
179
     *
180
     * @param string $name The name of the class to get the closure for
181
     * @param array $rule The rule to base the instance on
182
     * @return \Closure A closure that will create the appropriate object when called
183
     */
184
    private function getClosure($name, array $rule, \ReflectionClass $class)
185
    {
186
        $constructor = $class->getConstructor();
187
        // Create parameter-generating closure in order to cache reflection on the parameters.
188
        // This way $reflectmethod->getParameters() only ever gets called once.
189
        $params = ($constructor) ? $this->getParams($constructor, $rule) : null;
190
191
        // PHP throws a fatal error rather than an exception when trying to
192
        // instantiate an interface - detect it and throw an exception instead
193
        if ($class->isInterface()) {
194
            return function() {
195
                throw new \InvalidArgumentException('Cannot instantiate interface');
196
            };
197
        }
198
199
        // Get a closure based on the type of object being created: shared, normal, or constructorless
200
        if (isset($rule['shared']) && $rule['shared'] === true) {
201
            return function(array $args, array $share) use ($name, $class, $constructor, $params) {
202
                if ($constructor) {
203
                    try {
204
                        // Shared instance: create without calling constructor (and write to \$name and $name, see issue #68)
205
                        $this->instances[$name] = $class->newInstanceWithoutConstructor();
206
                        // Now call constructor after constructing all dependencies. Avoids problems with cyclic references (issue #7)
207
                        $constructor->invokeArgs($this->instances[$name], $params($args, $share));
208
                    } catch (\ReflectionException $e) {
209
                        $this->instances[$name] = $class->newInstanceArgs($params($args, $share));
210
                    }
211
                } else {
212
                    $this->instances[$name] = $class->newInstanceWithoutConstructor();
213
                }
214
215
                $this->instances[self::normalizeNamespace($name)] = $this->instances[$name];
216
217
                return $this->instances[$name];
218
            };
219
        }
220
221
        if ($params) {
222
            // This class has dependencies, call the $params closure to generate them based on $args and $share
223
            return function(array $args, array $share) use ($class, $params) {
224
                return $class->newInstanceArgs($params($args, $share));
225
            };
226
        }
227
228
        return function() use ($class) {
229
            // No constructor arguments, just instantiate the class
230
            return new $class->name();
231
        };
232
    }
233
234
    /**
235
     * Returns a closure that generates arguments for $method based on $rule and any $args passed into the closure
236
     *
237
     * @param \ReflectionMethod $method A reflection of the method to inspect
238
     * @param array $rule The ruleset to use in interpreting what the params should be
239
     * @return \Closure A closure that uses the cached information to generate the method's arguments
240
     */
241
    private function getParams(\ReflectionMethod $method, array $rule)
242
    {
243
        $paramInfo = []; // Caches some information about the parameter so (slow) reflection isn't needed every time
244
        foreach ($method->getParameters() as $param) {
245
            // get the class hint of each param, if there is one
246
            $class = ($class = $param->getClass()) ? $class->name : null;
247
            // determine if the param can be null, if we need to substitute a
248
            // different class, or if we need to force a new instance for it
249
            $paramInfo[] = [
250
                $class,
251
                $param,
252
                isset($rule['substitutions']) && \array_key_exists($class, $rule['substitutions']),
253
            ];
254
        }
255
        $php56 = \method_exists('ReflectionParameter', 'isVariadic'); // marginally faster than checking PHP_VERSION
256
257
        // Return a closure that uses the cached information to generate the arguments for the method
258
        return function(array $args, array $share = []) use ($paramInfo, $rule, $php56) {
259
            // Now merge all the possible parameters: user-defined in the rule via constructParams,
260
            // shared instances, and the $args argument from $dice->create()
261
            if (!empty($share) || isset($rule['constructParams'])) {
262
                $args = \array_merge(
263
                    $args,
264
                    (isset($rule['constructParams'])) ? $this->expand($rule['constructParams'], $share) : [],
265
                    $share
266
                );
267
            }
268
269
            $parameters = [];
270
271
            // Now find a value for each method parameter
272
            foreach ($paramInfo as $pi) {
273
                list($class, $param, $sub) = $pi;
274
275
                // First, loop through $args and see if each value can match the current parameter based on type hint
276
                if (!empty($args)) { // This if statement actually gives a ~10% speed increase when $args isn't set
277
                    foreach ($args as $i => $arg) {
278
                        if ($class !== null
279
                            && ($arg instanceof $class || ($arg === null && $param->allowsNull()))
280
                        ) {
281
                            // The argument matches, store and remove from $args so it won't wrongly match another parameter
282
                            $parameters[] = \array_splice($args, $i, 1)[0];
283
                            continue 2; //Move on to the next parameter
284
                        }
285
                    }
286
                }
287
288
                // When nothing from $args matches but a class is type hinted, create an instance to use, using a substitution if set
289
                if ($class !== null) {
290
                    try {
291
                        $parameters[] = ($sub)
292
                            ? $this->expand($rule['substitutions'][$class], $share, true)
293
                            : $this->create($class, [], $share);
294
                    }
295
                    catch (\InvalidArgumentException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
296
297
                    }
298
                    continue;
299
                }
300
301
                // Variadic functions will only have one argument. To account for those, append any remaining arguments to the list
302
                if ($php56 && $param->isVariadic()) {
303
                    $parameters = array_merge($parameters, $args);
304
                    continue;
305
                }
306
307
                // There is no type hint, so take the next available value from $args (and remove from $args to stop it being reused)
308
                if (!empty($args)) {
309
                    $parameters[] = $this->expand(\array_shift($args));
310
                    continue;
311
                }
312
313
                // There's no type hint and nothing left in $args, so provide the default value or null
314
                $parameters[] = ($param->isDefaultValueAvailable()) ? $param->getDefaultValue() : null;
315
            }
316
317
            return ($php56) ? $parameters : array_merge($parameters, $args);
318
        };
319
    }
320
321
    /**
322
     * Looks for Dice::INSTANCE or Dice::CONSTANT array keys in $param, and when found, returns an object based on the value.
323
     * See {@link https:// r.je/dice.html#example3-1}
324
     *
325
     * @param string|array $param
326
     * @param array $share Array of instances from 'shareInstances', required for calls to `create`
327
     * @param bool $createFromString
328
     * @return mixed
329
     */
330
    private function expand($param, array $share = [], $createFromString = false)
331
    {
332
        if (!\is_array($param)) {
333
            // doesn't need any processing
334
            return (is_string($param) && $createFromString) ? $this->create($param) : $param;
335
        }
336
337
        if (isset($param[self::CONSTANT])) {
338
            return constant($param[self::CONSTANT]);
339
        }
340
341
        if (!isset($param[self::INSTANCE])) {
342
            // not a lazy instance, so recursively search for any self::INSTANCE keys on deeper levels
343
            foreach ($param as $name => $value) {
344
                $param[$name] = $this->expand($value, $share);
345
            }
346
347
            return $param;
348
        }
349
350
        // Check for 'params' which allows parameters to be sent to the instance when it's created
351
        // Either as a callback method or to the constructor of the instance
352
        $args = isset($param['params']) ? $this->expand($param['params']) : [];
353
354
        // for [self::INSTANCE => ['className', 'methodName'] construct the instance before calling it
0 ignored issues
show
Unused Code Comprehensibility introduced by
36% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
355
        if (\is_array($param[self::INSTANCE])) {
356
            $param[self::INSTANCE][0] = $this->expand($param[self::INSTANCE][0], $share, true);
357
        }
358
359
        if (\is_callable($param[self::INSTANCE])) {
360
            // it's a lazy instance formed by a function. Call or return the value stored under the key self::INSTANCE
361
            if (isset($param['params'])) {
362
                return \call_user_func_array($param[self::INSTANCE], $args);
363
            }
364
365
            return \call_user_func($param[self::INSTANCE]);
366
        }
367
368
        if (\is_string($param[self::INSTANCE])) {
369
            // it's a lazy instance's class name string
370
            return $this->create($param[self::INSTANCE], \array_merge($args, $share));
371
        }
372
        // if it's not a string, it's malformed. *shrug*
373
    }
374
375
    /**
376
     * @param string $name
377
     */
378
    private static function normalizeName($name)
379
    {
380
        return \strtolower(self::normalizeNamespace($name));
381
    }
382
383
    /**
384
     * @param string $name
385
     */
386
    private static function normalizeNamespace($name)
387
    {
388
        return \ltrim($name, '\\');
389
    }
390
}
391