Completed
Push — master ( 8b7387...210736 )
by Garrett
02:20
created

Dice::addRule()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 14
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 9
nc 4
nop 3
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
    /**
19
     * @var array $rules Rules which have been set using addRule()
20
     */
21
    private $rules = [];
22
23
    /**
24
     * @var array $cache A cache of closures based on class name so each class is only reflected once
25
     */
26
    private $cache = [];
27
28
    /**
29
     * @var array $instances Stores any instances marked as 'shared' so create() can return the same instance
30
     */
31
    private $instances = [];
32
33
    /**
34
     * Constructor which allows setting a default ruleset to apply to all objects.
35
     */
36
    public function __construct($defaultRule = [])
37
    {
38
        if (!empty($defaultRule)) {
39
            $this->rules['*'] = $defaultRule;
40
        }
41
    }
42
43
    /**
44
     * Adds a rule $rule to the class $classname.
45
     *
46
     * The container can be fully configured using rules provided by associative arrays.
47
     * See {@link https://r.je/dice.html#example3} for a description of the rules.
48
     *
49
     * @param string $classname The name of the class to add the rule for
50
     * @param array $rule The rule to add to it
51
     */
52
    public function addRule($classname, $rule, $swap = false)
53
    {
54
        if ($swap) {
55
            $temp = $rule;
56
            $rule = $classname;
57
            $classname = $temp;
58
        }
59
        if (isset($rule['instanceOf'])
60
            && (!\array_key_exists('inherit', $rule) || $rule['inherit'] === true)
61
        ) {
62
            $rule = \array_merge_recursive($this->getRule($rule['instanceOf']), $rule);
63
        }
64
        $this->rules[self::normalizeName($classname)] = \array_merge_recursive($this->getRule($classname), $rule);
0 ignored issues
show
Bug introduced by
It seems like $classname defined by $temp on line 57 can also be of type array; however, Dice\Dice::getRule() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
65
    }
66
67
    /**
68
     * Returns the rule that will be applied to the class $matching during create().
69
     *
70
     * @param string $name The name of the ruleset to get - can be a class or not
71
     * @return array Ruleset that applies when instantiating the given name
72
     */
73
    public function getRule($name)
74
    {
75
        // first, check for exact match
76
        $normalname = self::normalizeName($name);
77
78
        if (isset($this->rules[$normalname])) {
79
            return $this->rules[$normalname];
80
        }
81
82
        // next, look for a rule where:
83
        foreach ($this->rules as $key => $rule) {
84
            if ($key !== '*'                    // it's not the default rule,
85
                && \is_subclass_of($name, $key) // its name is a parent class of what we're looking for,
86
                && empty($rule['instanceOf'])   // it's not a named instance,
87
                && (!array_key_exists('inherit', $rule) || $rule['inherit'] === true) // and it applies to subclasses
88
            ) {
89
                return $rule;
90
            }
91
        }
92
93
        // if we get here, return the default rule if it's set
94
        return (isset($this->rules['*'])) ? $this->rules['*'] : [];
95
    }
96
97
    /**
98
     * Returns a fully constructed object based on $classname using $args and $share as constructor arguments
99
     *
100
     * @param string $classname The name of the class to instantiate
101
     * @param array $args An array with any additional arguments to be passed into the constructor
102
     * @param array $share Whether the same class instance should be passed around each time
103
     * @return object A fully constructed object based on the specified input arguments
104
     */
105
    public function create($classname, array $args = [], array $share = [])
106
    {
107
        if (!empty($this->instances[$classname])) {
108
            // we've already created a shared instance so return it to save the closure call.
109
            return $this->instances[$classname];
110
        }
111
112
        // so now, we either need a new instance or just don't have one stored
113
        // but if we have the closure stored that creates it, call that
114
        if (!empty($this->cache[$classname])) {
115
            return $this->cache[$classname]($args, $share);
116
        }
117
118
        $rule = $this->getRule($classname);
119
        // Reflect the class for inspection, this should only ever be done once per class and then be cached
120
        $class = new \ReflectionClass(isset($rule['instanceOf']) ? $rule['instanceOf'] : $classname);
121
        $closure = $this->getClosure($classname, $rule, $class);
122
123
        //If there are shared instances, create them and merge them with shared instances higher up the object graph
124
        if (isset($rule['shareInstances'])) {
125
            $closure = function(array $args, array $share) use ($closure, $rule) {
126
                return $closure($args, array_merge($args, $share, array_map([$this, 'create'], $rule['shareInstances'])));
127
            };
128
        }
129
130
        // When $rule['call'] is set, wrap the closure in another closure which calls the required methods after constructing the object.
131
        // By putting this in a closure, the loop is never executed unless call is actually set.
132
        if (isset($rule['call'])) {
133
            $closure = function(array $args, array $share) use ($closure, $class, $rule) {
134
                // Construct the object using the original closure
135
                $object = $closure($args, $share);
136
137
                foreach ($rule['call'] as $call) {
138
                    // Generate the method arguments using getParams() and call the returned closure
139
                    // (in php7 it will be ()() rather than __invoke)
140
                    $shareRule = ['shareInstances' => isset($rule['shareInstances']) ? $rule['shareInstances'] : []];
141
                    $callMeMaybe = isset($call[1]) ? $call[1] : [];
142
                    call_user_func_array(
143
                        [$object, $call[0]],
144
                        $this->getParams($class->getMethod($call[0]), $shareRule)
145
                            ->__invoke($this->expand($callMeMaybe))
146
                    );
147
                }
148
149
                return $object;
150
            };
151
        }
152
153
        $this->cache[$classname] = $closure;
154
155
        return $this->cache[$classname]($args, $share);
156
    }
157
158
    /**
159
     * Returns a closure for creating object $name based on $rule, caching the reflection object for later use.
160
     *
161
     * The container can be fully configured using rules provided by associative arrays.
162
     * See {@link https://r.je/dice.html#example3} for a description of the rules.
163
     *
164
     * @param string $name The name of the class to get the closure for
165
     * @param array $rule The rule to base the instance on
166
     * @return callable A closure that will create the appropriate object when called
167
     */
168
    private function getClosure($name, array $rule, \ReflectionClass $class)
169
    {
170
        $constructor = $class->getConstructor();
171
        // Create parameter-generating closure in order to cache reflection on the parameters.
172
        // This way $reflectmethod->getParameters() only ever gets called once.
173
        $params = ($constructor) ? $this->getParams($constructor, $rule) : null;
174
175
        // Get a closure based on the type of object being created: shared, normal, or constructorless
176
        if (isset($rule['shared']) && $rule['shared'] === true) {
177
            return function(array $args, array $share) use ($name, $class, $constructor, $params) {
178
                if ($constructor) {
179
                    try {
180
                        // Shared instance: create without calling constructor (and write to \$name and $name, see issue #68)
181
                        $this->instances[$name] = $class->newInstanceWithoutConstructor();
182
                        // Now call constructor after constructing all dependencies. Avoids problems with cyclic references (issue #7)
183
                        $constructor->invokeArgs($this->instances[$name], $params($args, $share));
184
                    } catch (\ReflectionException $e) {
185
                        $this->instances[$name] = $class->newInstanceArgs($params($args, $share));
186
                    }
187
                } else {
188
                    $this->instances[$name] = $class->newInstanceWithoutConstructor();
189
                }
190
191
                $this->instances[self::normalizeNamespace($name)] = $this->instances[$name];
192
193
                return $this->instances[$name];
194
            };
195
        }
196
197
        if ($params) {
198
            // This class has dependencies, call the $params closure to generate them based on $args and $share
199
            return function(array $args, array $share) use ($class, $params) {
200
                return $class->newInstanceArgs($params($args, $share));
201
            };
202
        }
203
204
        return function() use ($class) {
205
            // No constructor arguments, just instantiate the class
206
            return new $class->name();
207
        };
208
    }
209
210
    /**
211
     * Returns a closure that generates arguments for $method based on $rule and any $args passed into the closure
212
     *
213
     * @param ReflectionMethod $method A reflection of the method to inspect
0 ignored issues
show
Documentation introduced by
Should the type for parameter $method not be \ReflectionMethod?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
214
     * @param array $rule The ruleset to use in interpreting what the params should be
215
     * @return callable A closure that uses the cached information to generate the method's arguments
216
     */
217
    private function getParams(\ReflectionMethod $method, array $rule)
218
    {
219
        $paramInfo = []; // Caches some information about the parameter so (slow) reflection isn't needed every time
220
        foreach ($method->getParameters() as $param) {
221
            // get the class hint of each param, if there is one
222
            $class = ($class = $param->getClass()) ? $class->name : null;
223
            // determine if the param can be null, if we need to substitute a
224
            // different class, or if we need to force a new instance for it
225
            $paramInfo[] = [
226
                $class,
227
                $param,
228
                isset($rule['substitutions']) && \array_key_exists($class, $rule['substitutions']),
229
            ];
230
        }
231
232
        // Return a closure that uses the cached information to generate the arguments for the method
233
        return function(array $args, array $share = []) use ($paramInfo, $rule) {
234
            // Now merge all the possible parameters: user-defined in the rule via constructParams,
235
            // shared instances, and the $args argument from $dice->create()
236
            if (!empty($share) || isset($rule['constructParams'])) {
237
                $args = \array_merge(
238
                    $args,
239
                    (isset($rule['constructParams'])) ? $this->expand($rule['constructParams'], $share) : [],
240
                    $share
241
                );
242
            }
243
244
            $parameters = [];
245
            $php56 = \method_exists($param, 'isVariadic');
0 ignored issues
show
Bug introduced by
The variable $param seems only to be defined at a later point. Did you maybe move this code here without moving the variable definition?

This error can happen if you refactor code and forget to move the variable initialization.

Let’s take a look at a simple example:

function someFunction() {
    $x = 5;
    echo $x;
}

The above code is perfectly fine. Now imagine that we re-order the statements:

function someFunction() {
    echo $x;
    $x = 5;
}

In that case, $x would be read before it is initialized. This was a very basic example, however the principle is the same for the found issue.

Loading history...
246
247
            // Now find a value for each method parameter
248
            foreach ($paramInfo as $pi) {
249
                list($class, $param, $sub) = $pi;
250
251
                // First, loop through $args and see if each value can match the current parameter based on type hint
252
                if (!empty($args)) { // This if statement actually gives a ~10% speed increase when $args isn't set
253
                    foreach ($args as $i => $arg) {
254
                        if ($class !== null
255
                            && ($arg instanceof $class || ($arg === null && $param->allowsNull()))
256
                        ) {
257
                            // The argument matches, store and remove from $args so it won't wrongly match another parameter
258
                            $parameters[] = \array_splice($args, $i, 1)[0];
259
                            continue 2; //Move on to the next parameter
260
                        }
261
                    }
262
                }
263
264
                // When nothing from $args matches but a class is type hinted, create an instance to use, using a substitution if set
265
                if ($class !== null) {
266
                    $parameters[] = ($sub)
267
                        ? $this->expand($rule['substitutions'][$class], $share, true)
268
                        : $this->create($class, [], $share);
269
                    continue;
270
                }
271
272
                // Variadic functions will only have one argument. To account for those, append any remaining arguments to the list
273
                if ($php56 && $param->isVariadic()) {
274
                    $parameters = array_merge($parameters, $args);
275
                    continue;
276
                }
277
278
                // There is no type hint, so take the next available value from $args (and remove from $args to stop it being reused)
279
                if (!empty($args)) {
280
                    $parameters[] = $this->expand(\array_shift($args));
281
                    continue;
282
                }
283
284
                // There's no type hint and nothing left in $args, so provide the default value or null
285
                $parameters[] = ($param->isDefaultValueAvailable()) ? $param->getDefaultValue() : null;
286
            }
287
288
            return ($php56) ? $parameters : array_merge($parameters, $args);
289
        };
290
    }
291
292
    /**
293
     * Looks for 'instance' array keys in $param, and when found, returns an object based on the value.
294
     * See {@link https:// r.je/dice.html#example3-1}
295
     *
296
     * @param string|array $param
297
     * @param array $share Whether this class instance will be passed around each time
298
     * @param bool $createFromString
299
     * @return mixed
300
     */
301
    private function expand($param, array $share = [], $createFromString = false)
302
    {
303
        if (!\is_array($param)) {
304
            // doesn't need any processing
305
            return (is_string($param) && $createFromString) ? $this->create($param) : $param;
306
        }
307
308
        if (!isset($param['instance'])) {
309
            // not a lazy instance, so recursively search for any 'instance' keys on deeper levels
310
            foreach ($param as $name => $value) {
311
                $param[$name] = $this->expand($value, $share);
312
            }
313
314
            return $param;
315
        }
316
317
        $args = isset($param['params']) ? $this->expand($param['params']) : [];
318
319
        // for ['instance' => ['className', 'methodName'] construct the instance before calling it
320
        if (\is_array($param['instance'])) {
321
            $param['instance'][0] = $this->expand($param['instance'][0], $share, true);
322
        }
323
324
        if (\is_callable($param['instance'])) {
325
            // it's a lazy instance formed by a function. Call or return the value stored under the key 'instance'
326
            if (isset($param['params'])) {
327
                return \call_user_func_array($param['instance'], $args);
328
            }
329
330
            return \call_user_func($param['instance']);
331
        }
332
333
        if (\is_string($param['instance'])) {
334
            // it's a lazy instance's class name string
335
            return $this->create($param['instance'], \array_merge($args, $share));
336
        }
337
        // if it's not a string, it's malformed. *shrug*
338
    }
339
340
    /**
341
     *
342
     */
343
    private static function normalizeName($name)
344
    {
345
        return \strtolower(self::normalizeNamespace($name));
346
    }
347
348
    /**
349
     *
350
     */
351
    private static function normalizeNamespace($name)
352
    {
353
        return \ltrim($name, '\\');
354
    }
355
}
356