Completed
Push — master ( deca00...5388ff )
by Sam
24s
created

Extensible::extend()   C

Complexity

Conditions 11
Paths 8

Size

Total Lines 39
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 23
nc 8
nop 8
dl 0
loc 39
rs 5.2653
c 0
b 0
f 0

How to fix   Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
3
namespace SilverStripe\Core;
4
5
use InvalidArgumentException;
6
use SilverStripe\Core\Config\Config;
7
use SilverStripe\Core\Injector\Injector;
8
9
/**
10
 * Allows an object to have extensions applied to it.
11
 *
12
 * Bootstrap by calling $this->constructExtensions() in your class constructor.
13
 *
14
 * Requires CustomMethods trait
15
 */
16
trait Extensible
17
{
18
    use CustomMethods;
19
20
    /**
21
     * An array of extension names and parameters to be applied to this object upon construction.
22
     *
23
     * Example:
24
     * <code>
25
     * private static $extensions = array (
26
     *   'Hierarchy',
27
     *   "Version('Stage', 'Live')"
28
     * );
29
     * </code>
30
     *
31
     * Use {@link Object::add_extension()} to add extensions without access to the class code,
32
     * e.g. to extend core classes.
33
     *
34
     * Extensions are instantiated together with the object and stored in {@link $extension_instances}.
35
     *
36
     * @var array $extensions
37
     * @config
38
     */
39
    private static $extensions = null;
40
41
    private static $classes_constructed = array();
42
43
    /**
44
     * Classes that cannot be extended
45
     *
46
     * @var array
47
     */
48
    private static $unextendable_classes = array(
49
        'SilverStripe\\Core\\Object',
50
        'SilverStripe\\View\\ViewableData',
51
        'SilverStripe\\Control\\RequestHandler'
52
    );
53
54
    /**
55
     * @var Extension[] all current extension instances.
56
     */
57
    protected $extension_instances = array();
58
59
    /**
60
     * List of callbacks to call prior to extensions having extend called on them,
61
     * each grouped by methodName.
62
     *
63
     * Top level array is method names, each of which is an array of callbacks for that name.
64
     *
65
     * @var callable[][]
66
     */
67
    protected $beforeExtendCallbacks = array();
68
69
    /**
70
     * List of callbacks to call after extensions having extend called on them,
71
     * each grouped by methodName.
72
     *
73
     * Top level array is method names, each of which is an array of callbacks for that name.
74
     *
75
     * @var callable[][]
76
     */
77
    protected $afterExtendCallbacks = array();
78
79
    /**
80
     * Allows user code to hook into Object::extend prior to control
81
     * being delegated to extensions. Each callback will be reset
82
     * once called.
83
     *
84
     * @param string $method The name of the method to hook into
85
     * @param callable $callback The callback to execute
86
     */
87
    protected function beforeExtending($method, $callback)
88
    {
89
        if (empty($this->beforeExtendCallbacks[$method])) {
90
            $this->beforeExtendCallbacks[$method] = array();
91
        }
92
        $this->beforeExtendCallbacks[$method][] = $callback;
93
    }
94
95
    /**
96
     * Allows user code to hook into Object::extend after control
97
     * being delegated to extensions. Each callback will be reset
98
     * once called.
99
     *
100
     * @param string $method The name of the method to hook into
101
     * @param callable $callback The callback to execute
102
     */
103
    protected function afterExtending($method, $callback)
104
    {
105
        if (empty($this->afterExtendCallbacks[$method])) {
106
            $this->afterExtendCallbacks[$method] = array();
107
        }
108
        $this->afterExtendCallbacks[$method][] = $callback;
109
    }
110
111
    protected function constructExtensions()
112
    {
113
        $class = get_class($this);
114
115
        // Register this trait as a method source
116
        $this->registerExtraMethodCallback('defineExtensionMethods', function () {
117
            $this->defineExtensionMethods();
118
        });
119
120
        // Setup all extension instances for this instance
121
        foreach (ClassInfo::ancestry($class) as $class) {
122
            if (in_array($class, self::$unextendable_classes)) {
123
                continue;
124
            }
125
            $extensions = Config::inst()->get($class, 'extensions', true);
126
127
            if ($extensions) {
128
                foreach ($extensions as $extension) {
129
                    $instance = Object::create_from_string($extension);
130
                    $instance->setOwner(null, $class);
131
                    $this->extension_instances[$instance->class] = $instance;
132
                }
133
            }
134
        }
135
136
        if (!isset(self::$classes_constructed[$class])) {
137
            $this->defineMethods();
138
            self::$classes_constructed[$class] = true;
139
        }
140
    }
141
142
    /**
143
     * Adds any methods from {@link Extension} instances attached to this object.
144
     * All these methods can then be called directly on the instance (transparently
145
     * mapped through {@link __call()}), or called explicitly through {@link extend()}.
146
     *
147
     * @uses addMethodsFrom()
148
     */
149
    protected function defineExtensionMethods()
150
    {
151
        if (!empty($this->extension_instances)) {
152
            foreach (array_keys($this->extension_instances) as $key) {
153
                $this->addMethodsFrom('extension_instances', $key);
154
            }
155
        }
156
    }
157
158
159
    /**
160
     * Add an extension to a specific class.
161
     *
162
     * The preferred method for adding extensions is through YAML config,
163
     * since it avoids autoloading the class, and is easier to override in
164
     * more specific configurations.
165
     *
166
     * As an alternative, extensions can be added to a specific class
167
     * directly in the {@link Object::$extensions} array.
168
     * See {@link SiteTree::$extensions} for examples.
169
     * Keep in mind that the extension will only be applied to new
170
     * instances, not existing ones (including all instances created through {@link singleton()}).
171
     *
172
     * @see http://doc.silverstripe.org/framework/en/trunk/reference/dataextension
173
     * @param string $classOrExtension Class that should be extended - has to be a subclass of {@link Object}
174
     * @param string $extension Subclass of {@link Extension} with optional parameters
175
     *  as a string, e.g. "Versioned" or "Translatable('Param')"
176
     * @return bool Flag if the extension was added
177
     */
178
    public static function add_extension($classOrExtension, $extension = null)
179
    {
180
        if (func_num_args() > 1) {
181
            $class = $classOrExtension;
182
        } else {
183
            $class = get_called_class();
184
            $extension = $classOrExtension;
185
        }
186
187
        if (!preg_match('/^([^(]*)/', $extension, $matches)) {
188
            return false;
189
        }
190
        $extensionClass = $matches[1];
191
        if (!class_exists($extensionClass)) {
192
            user_error(
193
                sprintf('Object::add_extension() - Can\'t find extension class for "%s"', $extensionClass),
194
                E_USER_ERROR
195
            );
196
        }
197
198
        if (!is_subclass_of($extensionClass, 'SilverStripe\\Core\\Extension')) {
199
            user_error(
200
                sprintf('Object::add_extension() - Extension "%s" is not a subclass of Extension', $extensionClass),
201
                E_USER_ERROR
202
            );
203
        }
204
205
        // unset some caches
206
        $subclasses = ClassInfo::subclassesFor($class);
207
        $subclasses[] = $class;
208
209
        if ($subclasses) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $subclasses of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
210
            foreach ($subclasses as $subclass) {
211
                unset(self::$classes_constructed[$subclass]);
212
                unset(self::$extra_methods[$subclass]);
213
            }
214
        }
215
216
        Config::modify()
217
            ->merge($class, 'extensions', array(
218
                $extension
219
            ));
220
221
        Injector::inst()->unregisterNamedObject($class);
222
223
        // load statics now for DataObject classes
224
        if (is_subclass_of($class, 'SilverStripe\\ORM\\DataObject')) {
225
            if (!is_subclass_of($extensionClass, 'SilverStripe\\ORM\\DataExtension')) {
226
                user_error("$extensionClass cannot be applied to $class without being a DataExtension", E_USER_ERROR);
227
            }
228
        }
229
        return true;
230
    }
231
232
233
    /**
234
     * Remove an extension from a class.
235
     * Note: This will not remove extensions from parent classes, and must be called
236
     * directly on the class assigned the extension.
237
     *
238
     * Keep in mind that this won't revert any datamodel additions
239
     * of the extension at runtime, unless its used before the
240
     * schema building kicks in (in your _config.php).
241
     * Doesn't remove the extension from any {@link Object}
242
     * instances which are already created, but will have an
243
     * effect on new extensions.
244
     * Clears any previously created singletons through {@link singleton()}
245
     * to avoid side-effects from stale extension information.
246
     *
247
     * @todo Add support for removing extensions with parameters
248
     *
249
     * @param string $extension class name of an {@link Extension} subclass, without parameters
250
     */
251
    public static function remove_extension($extension)
252
    {
253
        $class = get_called_class();
254
255
        // Build filtered extension list
256
        $found = false;
257
        $config = Config::inst()->get($class, 'extensions', true) ?: [];
258
        foreach ($config as $key => $candidate) {
259
            // extensions with parameters will be stored in config as ExtensionName("Param").
260
            if (strcasecmp($candidate, $extension) === 0 ||
261
                stripos($candidate, $extension.'(') === 0
262
            ) {
263
                $found = true;
264
                unset($config[$key]);
265
            }
266
        }
267
        // Don't dirty cache if no changes
268
        if (!$found) {
269
            return;
270
        }
271
        Config::modify()->set($class, 'extensions', $config);
272
273
        // unset singletons to avoid side-effects
274
        Injector::inst()->unregisterAllObjects();
275
276
        // unset some caches
277
        $subclasses = ClassInfo::subclassesFor($class);
278
        $subclasses[] = $class;
279
        if ($subclasses) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $subclasses of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
280
            foreach ($subclasses as $subclass) {
281
                unset(self::$classes_constructed[$subclass]);
282
                unset(self::$extra_methods[$subclass]);
283
            }
284
        }
285
    }
286
287
    /**
288
     * @param string $class
289
     * @param bool $includeArgumentString Include the argument string in the return array,
290
     *  FALSE would return array("Versioned"), TRUE returns array("Versioned('Stage','Live')").
291
     * @return array Numeric array of either {@link DataExtension} class names,
292
     *  or eval'ed class name strings with constructor arguments.
293
     */
294
    public static function get_extensions($class, $includeArgumentString = false)
295
    {
296
        $extensions = Config::forClass($class)->get('extensions', Config::EXCLUDE_EXTRA_SOURCES);
297
        if (empty($extensions)) {
298
            return array();
299
        }
300
301
        // Clean nullified named extensions
302
        $extensions = array_filter(array_values($extensions));
303
304
        if ($includeArgumentString) {
305
            return $extensions;
306
        } else {
307
            $extensionClassnames = array();
308
            if ($extensions) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $extensions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
309
                foreach ($extensions as $extension) {
310
                    $extensionClassnames[] = Extension::get_classname_without_arguments($extension);
311
                }
312
            }
313
            return $extensionClassnames;
314
        }
315
    }
316
317
318
    public static function get_extra_config_sources($class = null)
319
    {
320
        if ($class === null) {
321
            $class = get_called_class();
322
        }
323
324
        // If this class is unextendable, NOP
325
        if (in_array($class, self::$unextendable_classes)) {
326
            return null;
327
        }
328
329
        // Variable to hold sources in
330
        $sources = null;
331
332
        // Get a list of extensions
333
        $extensions = Config::inst()->get($class, 'extensions', true);
334
335
        if (!$extensions) {
336
            return null;
337
        }
338
339
        // Build a list of all sources;
340
        $sources = array();
341
342
        foreach ($extensions as $extension) {
343
            list($extensionClass, $extensionArgs) = Object::parse_class_spec($extension);
344
            $sources[] = $extensionClass;
345
346
            if (!class_exists($extensionClass)) {
347
                throw new InvalidArgumentException("$class references nonexistent $extensionClass in \$extensions");
348
            }
349
350
            call_user_func(array($extensionClass, 'add_to_class'), $class, $extensionClass, $extensionArgs);
351
352
            foreach (array_reverse(ClassInfo::ancestry($extensionClass)) as $extensionClassParent) {
353
                if (ClassInfo::has_method_from($extensionClassParent, 'get_extra_config', $extensionClassParent)) {
354
                    $extras = $extensionClassParent::get_extra_config($class, $extensionClass, $extensionArgs);
355
                    if ($extras) {
356
                        $sources[] = $extras;
357
                    }
358
                }
359
            }
360
        }
361
362
        return $sources;
363
    }
364
365
366
    /**
367
     * Return TRUE if a class has a specified extension.
368
     * This supports backwards-compatible format (static Object::has_extension($requiredExtension))
369
     * and new format ($object->has_extension($class, $requiredExtension))
370
     * @param string $classOrExtension if 1 argument supplied, the class name of the extension to
371
     *                               check for; if 2 supplied, the class name to test
372
     * @param string $requiredExtension used only if 2 arguments supplied
373
     * @param boolean $strict if the extension has to match the required extension and not be a subclass
374
     * @return bool Flag if the extension exists
375
     */
376
    public static function has_extension($classOrExtension, $requiredExtension = null, $strict = false)
377
    {
378
        //BC support
379
        if (func_num_args() > 1) {
380
            $class = $classOrExtension;
381
        } else {
382
            $class = get_called_class();
383
            $requiredExtension = $classOrExtension;
384
        }
385
386
        $requiredExtension = Extension::get_classname_without_arguments($requiredExtension);
387
        $extensions = self::get_extensions($class);
388
        foreach ($extensions as $extension) {
389
            if (strcasecmp($extension, $requiredExtension) === 0) {
390
                return true;
391
            }
392
            if (!$strict && is_subclass_of($extension, $requiredExtension)) {
393
                return true;
394
            }
395
        }
396
397
        return false;
398
    }
399
400
401
    /**
402
     * Calls a method if available on both this object and all applied {@link Extensions}, and then attempts to merge
403
     * all results into an array
404
     *
405
     * @param string $method the method name to call
406
     * @param mixed $a1
407
     * @param mixed $a2
408
     * @param mixed $a3
409
     * @param mixed $a4
410
     * @param mixed $a5
411
     * @param mixed $a6
412
     * @param mixed $a7
413
     * @return array List of results with nulls filtered out
414
     */
415
    public function invokeWithExtensions($method, &$a1 = null, &$a2 = null, &$a3 = null, &$a4 = null, &$a5 = null, &$a6 = null, &$a7 = null)
416
    {
417
        $result = array();
418
        if (method_exists($this, $method)) {
419
            $thisResult = $this->$method($a1, $a2, $a3, $a4, $a5, $a6, $a7);
420
            if ($thisResult !== null) {
421
                $result[] = $thisResult;
422
            }
423
        }
424
        $extras = $this->extend($method, $a1, $a2, $a3, $a4, $a5, $a6, $a7);
425
426
        return $extras ? array_merge($result, $extras) : $result;
427
    }
428
429
    /**
430
     * Run the given function on all of this object's extensions. Note that this method originally returned void, so if
431
     * you wanted to return results, you're hosed
432
     *
433
     * Currently returns an array, with an index resulting every time the function is called. Only adds returns if
434
     * they're not NULL, to avoid bogus results from methods just defined on the parent extension. This is important for
435
     * permission-checks through extend, as they use min() to determine if any of the returns is FALSE. As min() doesn't
436
     * do type checking, an included NULL return would fail the permission checks.
437
     *
438
     * The extension methods are defined during {@link __construct()} in {@link defineMethods()}.
439
     *
440
     * @param string $method the name of the method to call on each extension
441
     * @param mixed $a1
442
     * @param mixed $a2
443
     * @param mixed $a3
444
     * @param mixed $a4
445
     * @param mixed $a5
446
     * @param mixed $a6
447
     * @param mixed $a7
448
     * @return array
449
     */
450
    public function extend($method, &$a1 = null, &$a2 = null, &$a3 = null, &$a4 = null, &$a5 = null, &$a6 = null, &$a7 = null)
451
    {
452
        $values = array();
453
454
        if (!empty($this->beforeExtendCallbacks[$method])) {
455
            foreach (array_reverse($this->beforeExtendCallbacks[$method]) as $callback) {
456
                $value = call_user_func_array($callback, array(&$a1, &$a2, &$a3, &$a4, &$a5, &$a6, &$a7));
457
                if ($value !== null) {
458
                    $values[] = $value;
459
                }
460
            }
461
            $this->beforeExtendCallbacks[$method] = array();
462
        }
463
464
        if ($this->extension_instances) {
465
            foreach ($this->extension_instances as $instance) {
466
                if (method_exists($instance, $method)) {
467
                    $instance->setOwner($this);
468
                    $value = $instance->$method($a1, $a2, $a3, $a4, $a5, $a6, $a7);
469
                    if ($value !== null) {
470
                        $values[] = $value;
471
                    }
472
                    $instance->clearOwner();
473
                }
474
            }
475
        }
476
477
        if (!empty($this->afterExtendCallbacks[$method])) {
478
            foreach (array_reverse($this->afterExtendCallbacks[$method]) as $callback) {
479
                $value = call_user_func_array($callback, array(&$a1, &$a2, &$a3, &$a4, &$a5, &$a6, &$a7));
480
                if ($value !== null) {
481
                    $values[] = $value;
482
                }
483
            }
484
            $this->afterExtendCallbacks[$method] = array();
485
        }
486
487
        return $values;
488
    }
489
490
    /**
491
     * Get an extension instance attached to this object by name.
492
     *
493
     * @uses hasExtension()
494
     *
495
     * @param string $extension
496
     * @return Extension
497
     */
498
    public function getExtensionInstance($extension)
499
    {
500
        if ($this->hasExtension($extension)) {
501
            return $this->extension_instances[$extension];
502
        }
503
    }
504
505
    /**
506
     * Returns TRUE if this object instance has a specific extension applied
507
     * in {@link $extension_instances}. Extension instances are initialized
508
     * at constructor time, meaning if you use {@link add_extension()}
509
     * afterwards, the added extension will just be added to new instances
510
     * of the extended class. Use the static method {@link has_extension()}
511
     * to check if a class (not an instance) has a specific extension.
512
     * Caution: Don't use singleton(<class>)->hasExtension() as it will
513
     * give you inconsistent results based on when the singleton was first
514
     * accessed.
515
     *
516
     * @param string $extension Classname of an {@link Extension} subclass without parameters
517
     * @return bool
518
     */
519
    public function hasExtension($extension)
520
    {
521
        return isset($this->extension_instances[$extension]);
522
    }
523
524
    /**
525
     * Get all extension instances for this specific object instance.
526
     * See {@link get_extensions()} to get all applied extension classes
527
     * for this class (not the instance).
528
     *
529
     * @return array Map of {@link DataExtension} instances, keyed by classname.
530
     */
531
    public function getExtensionInstances()
532
    {
533
        return $this->extension_instances;
534
    }
535
}
536