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

Extensible::hasExtension()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
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