Passed
Push — master ( ea68ff...25edad )
by Daniel
12:24 queued 10s
created

Extensible::constructExtensions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Core;
4
5
use InvalidArgumentException;
6
use SilverStripe\Control\RequestHandler;
7
use SilverStripe\Core\Config\Config;
8
use SilverStripe\Core\Injector\Injector;
9
use SilverStripe\ORM\DataExtension;
10
use SilverStripe\ORM\DataObject;
11
use SilverStripe\View\ViewableData;
12
13
/**
14
 * Allows an object to have extensions applied to it.
15
 */
16
trait Extensible
17
{
18
    use CustomMethods {
19
        defineMethods as defineMethodsCustom;
20
    }
21
22
    /**
23
     * An array of extension names and parameters to be applied to this object upon construction.
24
     *
25
     * Example:
26
     * <code>
27
     * private static $extensions = array (
28
     *   'Hierarchy',
29
     *   "Version('Stage', 'Live')"
30
     * );
31
     * </code>
32
     *
33
     * Use {@link Object::add_extension()} to add extensions without access to the class code,
34
     * e.g. to extend core classes.
35
     *
36
     * Extensions are instantiated together with the object and stored in {@link $extension_instances}.
37
     *
38
     * @var array $extensions
39
     * @config
40
     */
41
    private static $extensions = [];
42
43
    /**
44
     * Classes that cannot be extended
45
     *
46
     * @var array
47
     */
48
    private static $unextendable_classes = array(
49
        ViewableData::class,
50
        RequestHandler::class,
51
    );
52
53
    /**
54
     * @var Extension[] all current extension instances, or null if not declared yet.
55
     */
56
    protected $extension_instances = null;
57
58
    /**
59
     * List of callbacks to call prior to extensions having extend called on them,
60
     * each grouped by methodName.
61
     *
62
     * Top level array is method names, each of which is an array of callbacks for that name.
63
     *
64
     * @var callable[][]
65
     */
66
    protected $beforeExtendCallbacks = array();
67
68
    /**
69
     * List of callbacks to call after extensions having extend called on them,
70
     * each grouped by methodName.
71
     *
72
     * Top level array is method names, each of which is an array of callbacks for that name.
73
     *
74
     * @var callable[][]
75
     */
76
    protected $afterExtendCallbacks = array();
77
78
    /**
79
     * Allows user code to hook into Object::extend prior to control
80
     * being delegated to extensions. Each callback will be reset
81
     * once called.
82
     *
83
     * @param string $method The name of the method to hook into
84
     * @param callable $callback The callback to execute
85
     */
86
    protected function beforeExtending($method, $callback)
87
    {
88
        if (empty($this->beforeExtendCallbacks[$method])) {
89
            $this->beforeExtendCallbacks[$method] = array();
90
        }
91
        $this->beforeExtendCallbacks[$method][] = $callback;
92
    }
93
94
    /**
95
     * Allows user code to hook into Object::extend after control
96
     * being delegated to extensions. Each callback will be reset
97
     * once called.
98
     *
99
     * @param string $method The name of the method to hook into
100
     * @param callable $callback The callback to execute
101
     */
102
    protected function afterExtending($method, $callback)
103
    {
104
        if (empty($this->afterExtendCallbacks[$method])) {
105
            $this->afterExtendCallbacks[$method] = array();
106
        }
107
        $this->afterExtendCallbacks[$method][] = $callback;
108
    }
109
110
    protected function defineMethods()
111
    {
112
        $this->defineMethodsCustom();
0 ignored issues
show
Bug introduced by
The method defineMethodsCustom() does not exist on SilverStripe\Core\Extensible. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

112
        $this->/** @scrutinizer ignore-call */ 
113
               defineMethodsCustom();
Loading history...
113
114
        // Define extension methods
115
        $this->defineExtensionMethods();
116
    }
117
118
    /**
119
     * Adds any methods from {@link Extension} instances attached to this object.
120
     * All these methods can then be called directly on the instance (transparently
121
     * mapped through {@link __call()}), or called explicitly through {@link extend()}.
122
     *
123
     * @uses addCallbackMethod()
124
     */
125
    protected function defineExtensionMethods()
126
    {
127
        $extensions = $this->getExtensionInstances();
128
        foreach ($extensions as $extensionClass => $extensionInstance) {
129
            foreach ($this->findMethodsFrom($extensionInstance) as $method) {
130
                $this->addCallbackMethod($method, function ($inst, $args) use ($method, $extensionClass) {
131
                    /** @var Extensible $inst */
132
                    $extension = $inst->getExtensionInstance($extensionClass);
133
                    try {
134
                        $extension->setOwner($inst);
135
                        return call_user_func_array([$extension, $method], $args);
136
                    } finally {
137
                        $extension->clearOwner();
138
                    }
139
                });
140
            }
141
        }
142
    }
143
144
    /**
145
     * Add an extension to a specific class.
146
     *
147
     * The preferred method for adding extensions is through YAML config,
148
     * since it avoids autoloading the class, and is easier to override in
149
     * more specific configurations.
150
     *
151
     * As an alternative, extensions can be added to a specific class
152
     * directly in the {@link Object::$extensions} array.
153
     * See {@link SiteTree::$extensions} for examples.
154
     * Keep in mind that the extension will only be applied to new
155
     * instances, not existing ones (including all instances created through {@link singleton()}).
156
     *
157
     * @see http://doc.silverstripe.org/framework/en/trunk/reference/dataextension
158
     * @param string $classOrExtension Class that should be extended - has to be a subclass of {@link Object}
159
     * @param string $extension Subclass of {@link Extension} with optional parameters
160
     *  as a string, e.g. "Versioned" or "Translatable('Param')"
161
     * @return bool Flag if the extension was added
162
     */
163
    public static function add_extension($classOrExtension, $extension = null)
164
    {
165
        if ($extension) {
166
            $class = $classOrExtension;
167
        } else {
168
            $class = get_called_class();
169
            $extension = $classOrExtension;
170
        }
171
172
        if (!preg_match('/^([^(]*)/', $extension, $matches)) {
173
            return false;
174
        }
175
        $extensionClass = $matches[1];
176
        if (!class_exists($extensionClass)) {
177
            user_error(
178
                sprintf('Object::add_extension() - Can\'t find extension class for "%s"', $extensionClass),
179
                E_USER_ERROR
180
            );
181
        }
182
183
        if (!is_subclass_of($extensionClass, 'SilverStripe\\Core\\Extension')) {
184
            user_error(
185
                sprintf('Object::add_extension() - Extension "%s" is not a subclass of Extension', $extensionClass),
186
                E_USER_ERROR
187
            );
188
        }
189
190
        // unset some caches
191
        $subclasses = ClassInfo::subclassesFor($class);
192
        $subclasses[] = $class;
193
        foreach ($subclasses as $subclass) {
194
            unset(self::$extra_methods[strtolower($subclass)]);
195
        }
196
197
        Config::modify()
198
            ->merge($class, 'extensions', array(
199
                $extension
200
            ));
201
202
        Injector::inst()->unregisterNamedObject($class);
203
204
        // load statics now for DataObject classes
205
        if (is_subclass_of($class, DataObject::class)) {
206
            if (!is_subclass_of($extensionClass, DataExtension::class)) {
207
                user_error("$extensionClass cannot be applied to $class without being a DataExtension", E_USER_ERROR);
208
            }
209
        }
210
        return true;
211
    }
212
213
214
    /**
215
     * Remove an extension from a class.
216
     * Note: This will not remove extensions from parent classes, and must be called
217
     * directly on the class assigned the extension.
218
     *
219
     * Keep in mind that this won't revert any datamodel additions
220
     * of the extension at runtime, unless its used before the
221
     * schema building kicks in (in your _config.php).
222
     * Doesn't remove the extension from any {@link Object}
223
     * instances which are already created, but will have an
224
     * effect on new extensions.
225
     * Clears any previously created singletons through {@link singleton()}
226
     * to avoid side-effects from stale extension information.
227
     *
228
     * @todo Add support for removing extensions with parameters
229
     *
230
     * @param string $extension class name of an {@link Extension} subclass, without parameters
231
     */
232
    public static function remove_extension($extension)
233
    {
234
        $class = get_called_class();
235
236
        // Build filtered extension list
237
        $found = false;
238
        $config = Config::inst()->get($class, 'extensions', Config::EXCLUDE_EXTRA_SOURCES | Config::UNINHERITED) ?: [];
239
        foreach ($config as $key => $candidate) {
240
            // extensions with parameters will be stored in config as ExtensionName("Param").
241
            if (strcasecmp($candidate, $extension) === 0 ||
242
                stripos($candidate, $extension . '(') === 0
243
            ) {
244
                $found = true;
245
                unset($config[$key]);
246
            }
247
        }
248
        // Don't dirty cache if no changes
249
        if (!$found) {
250
            return;
251
        }
252
        Config::modify()->set($class, 'extensions', $config);
253
254
        // Unset singletons
255
        Injector::inst()->unregisterObjects($class);
256
257
        // unset some caches
258
        $subclasses = ClassInfo::subclassesFor($class);
259
        $subclasses[] = $class;
260
        foreach ($subclasses as $subclass) {
261
            unset(self::$extra_methods[strtolower($subclass)]);
262
        }
263
    }
264
265
    /**
266
     * @param string $class If omitted, will get extensions for the current class
267
     * @param bool $includeArgumentString Include the argument string in the return array,
268
     *  FALSE would return array("Versioned"), TRUE returns array("Versioned('Stage','Live')").
269
     * @return array Numeric array of either {@link DataExtension} class names,
270
     *  or eval'ed class name strings with constructor arguments.
271
     */
272
    public static function get_extensions($class = null, $includeArgumentString = false)
273
    {
274
        if (!$class) {
275
            $class = get_called_class();
276
        }
277
278
        $extensions = Config::forClass($class)->get('extensions', Config::EXCLUDE_EXTRA_SOURCES);
279
        if (empty($extensions)) {
280
            return array();
281
        }
282
283
        // Clean nullified named extensions
284
        $extensions = array_filter(array_values($extensions));
285
286
        if ($includeArgumentString) {
287
            return $extensions;
288
        } else {
289
            $extensionClassnames = array();
290
            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...
291
                foreach ($extensions as $extension) {
292
                    $extensionClassnames[] = Extension::get_classname_without_arguments($extension);
293
                }
294
            }
295
            return $extensionClassnames;
296
        }
297
    }
298
299
300
    /**
301
     * Get extra config sources for this class
302
     *
303
     * @param string $class Name of class. If left null will return for the current class
304
     * @return array|null
305
     */
306
    public static function get_extra_config_sources($class = null)
307
    {
308
        if (!$class) {
309
            $class = get_called_class();
310
        }
311
312
        // If this class is unextendable, NOP
313
        if (in_array($class, self::$unextendable_classes)) {
314
            return null;
315
        }
316
317
        // Variable to hold sources in
318
        $sources = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $sources is dead and can be removed.
Loading history...
319
320
        // Get a list of extensions
321
        $extensions = Config::inst()->get($class, 'extensions', Config::EXCLUDE_EXTRA_SOURCES | Config::UNINHERITED);
322
323
        if (!$extensions) {
324
            return null;
325
        }
326
327
        // Build a list of all sources;
328
        $sources = array();
329
330
        foreach ($extensions as $extension) {
331
            list($extensionClass, $extensionArgs) = ClassInfo::parse_class_spec($extension);
332
            // Strip service name specifier
333
            $extensionClass = strtok($extensionClass, '.');
334
            $sources[] = $extensionClass;
335
336
            if (!class_exists($extensionClass)) {
337
                throw new InvalidArgumentException("$class references nonexistent $extensionClass in \$extensions");
338
            }
339
340
            call_user_func(array($extensionClass, 'add_to_class'), $class, $extensionClass, $extensionArgs);
341
342
            foreach (array_reverse(ClassInfo::ancestry($extensionClass)) as $extensionClassParent) {
343
                if (ClassInfo::has_method_from($extensionClassParent, 'get_extra_config', $extensionClassParent)) {
344
                    $extras = $extensionClassParent::get_extra_config($class, $extensionClass, $extensionArgs);
345
                    if ($extras) {
346
                        $sources[] = $extras;
347
                    }
348
                }
349
            }
350
        }
351
352
        return $sources;
353
    }
354
355
356
    /**
357
     * Return TRUE if a class has a specified extension.
358
     * This supports backwards-compatible format (static Object::has_extension($requiredExtension))
359
     * and new format ($object->has_extension($class, $requiredExtension))
360
     * @param string $classOrExtension Class to check extension for, or the extension name to check
361
     * if the second argument is null.
362
     * @param string $requiredExtension If the first argument is the parent class, this is the extension to check.
363
     * If left null, the first parameter will be treated as the extension.
364
     * @param boolean $strict if the extension has to match the required extension and not be a subclass
365
     * @return bool Flag if the extension exists
366
     */
367
    public static function has_extension($classOrExtension, $requiredExtension = null, $strict = false)
368
    {
369
        if ($requiredExtension) {
370
            $class = $classOrExtension;
371
        } else {
372
            $class = get_called_class();
373
            $requiredExtension = $classOrExtension;
374
        }
375
376
        $requiredExtension = Extension::get_classname_without_arguments($requiredExtension);
377
        $extensions = self::get_extensions($class);
378
        foreach ($extensions as $extension) {
379
            if (strcasecmp($extension, $requiredExtension) === 0) {
380
                return true;
381
            }
382
            if (!$strict && is_subclass_of($extension, $requiredExtension)) {
383
                return true;
384
            }
385
        }
386
387
        return false;
388
    }
389
390
391
    /**
392
     * Calls a method if available on both this object and all applied {@link Extensions}, and then attempts to merge
393
     * all results into an array
394
     *
395
     * @param string $method the method name to call
396
     * @param mixed ...$arguments List of arguments
397
     * @return array List of results with nulls filtered out
398
     */
399
    public function invokeWithExtensions($method, &...$arguments)
400
    {
401
        $result = array();
402
        if (method_exists($this, $method)) {
403
            $thisResult = $this->$method(...$arguments);
404
            if ($thisResult !== null) {
405
                $result[] = $thisResult;
406
            }
407
        }
408
        $extras = $this->extend($method, ...$arguments);
409
410
        return $extras ? array_merge($result, $extras) : $result;
411
    }
412
413
    /**
414
     * Run the given function on all of this object's extensions. Note that this method originally returned void, so if
415
     * you wanted to return results, you're hosed
416
     *
417
     * Currently returns an array, with an index resulting every time the function is called. Only adds returns if
418
     * they're not NULL, to avoid bogus results from methods just defined on the parent extension. This is important for
419
     * permission-checks through extend, as they use min() to determine if any of the returns is FALSE. As min() doesn't
420
     * do type checking, an included NULL return would fail the permission checks.
421
     *
422
     * The extension methods are defined during {@link __construct()} in {@link defineMethods()}.
423
     *
424
     * @param string $method the name of the method to call on each extension
425
     * @param mixed ...$arguments
426
     * @return array
427
     */
428
    public function extend($method, &...$arguments)
429
    {
430
        $values = array();
431
432
        if (!empty($this->beforeExtendCallbacks[$method])) {
433
            foreach (array_reverse($this->beforeExtendCallbacks[$method]) as $callback) {
434
                $value = call_user_func_array($callback, $arguments);
435
                if ($value !== null) {
436
                    $values[] = $value;
437
                }
438
            }
439
            $this->beforeExtendCallbacks[$method] = array();
440
        }
441
442
        foreach ($this->getExtensionInstances() as $instance) {
443
            // Prefer `extend` prefixed methods
444
            $instanceMethod = method_exists($instance, "extend{$method}")
445
                ? "extend{$method}"
446
                : (method_exists($instance, $method) ? $method : null);
447
            if ($instanceMethod) {
448
                try {
449
                    $instance->setOwner($this);
450
                    $value = $instance->$instanceMethod(...$arguments);
451
                } finally {
452
                    $instance->clearOwner();
453
                }
454
                if ($value !== null) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $value does not seem to be defined for all execution paths leading up to this point.
Loading history...
455
                    $values[] = $value;
456
                }
457
            }
458
        }
459
460
        if (!empty($this->afterExtendCallbacks[$method])) {
461
            foreach (array_reverse($this->afterExtendCallbacks[$method]) as $callback) {
462
                $value = call_user_func_array($callback, $arguments);
463
                if ($value !== null) {
464
                    $values[] = $value;
465
                }
466
            }
467
            $this->afterExtendCallbacks[$method] = array();
468
        }
469
470
        return $values;
471
    }
472
473
    /**
474
     * Get an extension instance attached to this object by name.
475
     *
476
     * @param string $extension
477
     * @return Extension|null
478
     */
479
    public function getExtensionInstance($extension)
480
    {
481
        $instances = $this->getExtensionInstances();
482
        if (array_key_exists($extension, $instances)) {
483
            return $instances[$extension];
484
        }
485
        // in case Injector has been used to replace an extension
486
        foreach ($instances as $instance) {
487
            if (is_a($instance, $extension)) {
488
                return $instance;
489
            }
490
        }
491
        return null;
492
    }
493
494
    /**
495
     * Returns TRUE if this object instance has a specific extension applied
496
     * in {@link $extension_instances}. Extension instances are initialized
497
     * at constructor time, meaning if you use {@link add_extension()}
498
     * afterwards, the added extension will just be added to new instances
499
     * of the extended class. Use the static method {@link has_extension()}
500
     * to check if a class (not an instance) has a specific extension.
501
     * Caution: Don't use singleton(<class>)->hasExtension() as it will
502
     * give you inconsistent results based on when the singleton was first
503
     * accessed.
504
     *
505
     * @param string $extension Classname of an {@link Extension} subclass without parameters
506
     * @return bool
507
     */
508
    public function hasExtension($extension)
509
    {
510
        return (bool) $this->getExtensionInstance($extension);
511
    }
512
513
    /**
514
     * Get all extension instances for this specific object instance.
515
     * See {@link get_extensions()} to get all applied extension classes
516
     * for this class (not the instance).
517
     *
518
     * This method also provides lazy-population of the extension_instances property.
519
     *
520
     * @return Extension[] Map of {@link DataExtension} instances, keyed by classname.
521
     */
522
    public function getExtensionInstances()
523
    {
524
        if (isset($this->extension_instances)) {
525
            return $this->extension_instances;
526
        }
527
528
        // Setup all extension instances for this instance
529
        $this->extension_instances = [];
530
        foreach (ClassInfo::ancestry(static::class) as $class) {
531
            if (in_array($class, self::$unextendable_classes)) {
532
                continue;
533
            }
534
            $extensions = Config::inst()->get($class, 'extensions', Config::UNINHERITED | Config::EXCLUDE_EXTRA_SOURCES);
535
536
            if ($extensions) {
537
                foreach ($extensions as $extension) {
538
                    $name = $extension;
539
                    // Allow service names of the form "%$ServiceName"
540
                    if (substr($name, 0, 2) == '%$') {
541
                        $name = substr($name, 2);
542
                    }
543
                    $name = trim(strtok($name, '('));
544
                    if (class_exists($name)) {
545
                        $name = ClassInfo::class_name($name);
546
                    }
547
                    $this->extension_instances[$name] = Injector::inst()->get($extension);
548
                }
549
            }
550
        }
551
552
        return $this->extension_instances;
553
    }
554
}
555