Completed
Push — master ( 1c3709...1468db )
by Daniel
11:48
created

Extensible::extend()   C

Complexity

Conditions 11
Paths 8

Size

Total Lines 30
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
c 1
b 1
f 0
dl 0
loc 30
rs 5.2653
cc 11
eloc 19
nc 8
nop 8

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\Framework\Core;
4
5
use ClassInfo;
6
use Config;
7
use Extension;
8
use Injector;
9
10
/**
11
 * Allows an object to have extensions applied to it.
12
 *
13
 * Bootstrap by calling $this->constructExtensions() in your class constructor.
14
 *
15
 * Requires CustomMethods trait
16
 */
17
trait Extensible {
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('Object', 'ViewableData', 'RequestHandler');
49
50
	/**
51
	 * @var array all current extension instances.
52
	 */
53
	protected $extension_instances = array();
54
55
	/**
56
	 * List of callbacks to call prior to extensions having extend called on them,
57
	 * each grouped by methodName.
58
	 *
59
	 * @var array[callable]
60
	 */
61
	protected $beforeExtendCallbacks = array();
62
63
	/**
64
	 * List of callbacks to call after extensions having extend called on them,
65
	 * each grouped by methodName.
66
	 *
67
	 * @var array[callable]
68
	 */
69
	protected $afterExtendCallbacks = array();
70
71
	/**
72
	 * Allows user code to hook into Object::extend prior to control
73
	 * being delegated to extensions. Each callback will be reset
74
	 * once called.
75
	 *
76
	 * @param string $method The name of the method to hook into
77
	 * @param callable $callback The callback to execute
78
	 */
79
	protected function beforeExtending($method, $callback) {
80
		if(empty($this->beforeExtendCallbacks[$method])) {
81
			$this->beforeExtendCallbacks[$method] = array();
82
		}
83
		$this->beforeExtendCallbacks[$method][] = $callback;
84
	}
85
86
	/**
87
	 * Allows user code to hook into Object::extend after control
88
	 * being delegated to extensions. Each callback will be reset
89
	 * once called.
90
	 *
91
	 * @param string $method The name of the method to hook into
92
	 * @param callable $callback The callback to execute
93
	 */
94
	protected function afterExtending($method, $callback) {
95
		if(empty($this->afterExtendCallbacks[$method])) {
96
			$this->afterExtendCallbacks[$method] = array();
97
		}
98
		$this->afterExtendCallbacks[$method][] = $callback;
99
	}
100
101
	protected function constructExtensions() {
102
		$class = get_class($this);
103
104
		// Register this trait as a method source
105
		$this->registerExtraMethodCallback('defineExtensionMethods', function() {
106
			$this->defineExtensionMethods();
107
		});
108
109
		// Setup all extension instances for this instance
110
		foreach(ClassInfo::ancestry($class) as $class) {
111
			if(in_array($class, self::$unextendable_classes)) continue;
112
			$extensions = Config::inst()->get($class, 'extensions',
113
				Config::UNINHERITED | Config::EXCLUDE_EXTRA_SOURCES);
114
115
			if($extensions) foreach($extensions as $extension) {
0 ignored issues
show
Bug introduced by
The expression $extensions of type array|integer|double|string|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
116
				$instance = \Object::create_from_string($extension);
117
				$instance->setOwner(null, $class);
118
				$this->extension_instances[$instance->class] = $instance;
119
			}
120
		}
121
122
		if(!isset(self::$classes_constructed[$class])) {
123
			$this->defineMethods();
124
			self::$classes_constructed[$class] = true;
125
		}
126
	}
127
128
	/**
129
	 * Adds any methods from {@link Extension} instances attached to this object.
130
	 * All these methods can then be called directly on the instance (transparently
131
	 * mapped through {@link __call()}), or called explicitly through {@link extend()}.
132
	 *
133
	 * @uses addMethodsFrom()
134
	 */
135
	protected function defineExtensionMethods() {
136
		if(!empty($this->extension_instances)) {
137
			foreach (array_keys($this->extension_instances) as $key) {
138
				$this->addMethodsFrom('extension_instances', $key);
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
		if(func_num_args() > 1) {
165
			$class = $classOrExtension;
166
		} else {
167
			$class = get_called_class();
168
			$extension = $classOrExtension;
169
		}
170
171
		if(!preg_match('/^([^(]*)/', $extension, $matches)) {
172
			return false;
173
		}
174
		$extensionClass = $matches[1];
175
		if(!class_exists($extensionClass)) {
176
			user_error(
177
				sprintf('Object::add_extension() - Can\'t find extension class for "%s"', $extensionClass),
178
				E_USER_ERROR
179
			);
180
		}
181
182
		if(!is_subclass_of($extensionClass, 'Extension')) {
183
			user_error(
184
				sprintf('Object::add_extension() - Extension "%s" is not a subclass of Extension', $extensionClass),
185
				E_USER_ERROR
186
			);
187
		}
188
189
		// unset some caches
190
		$subclasses = ClassInfo::subclassesFor($class);
191
		$subclasses[] = $class;
192
193
		if($subclasses) foreach($subclasses as $subclass) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $subclasses 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...
194
			unset(self::$classes_constructed[$subclass]);
195
			unset(self::$extra_methods[$subclass]);
196
		}
197
198
		Config::inst()->update($class, 'extensions', array($extension));
199
		Config::inst()->extraConfigSourcesChanged($class);
200
201
		Injector::inst()->unregisterNamedObject($class);
202
203
		// load statics now for DataObject classes
204
		if(is_subclass_of($class, 'DataObject')) {
205
			if(!is_subclass_of($extensionClass, 'DataExtension')) {
206
				user_error("$extensionClass cannot be applied to $class without being a DataExtension", E_USER_ERROR);
207
			}
208
		}
209
		return true;
210
	}
211
212
213
	/**
214
	 * Remove an extension from a class.
215
	 *
216
	 * Keep in mind that this won't revert any datamodel additions
217
	 * of the extension at runtime, unless its used before the
218
	 * schema building kicks in (in your _config.php).
219
	 * Doesn't remove the extension from any {@link Object}
220
	 * instances which are already created, but will have an
221
	 * effect on new extensions.
222
	 * Clears any previously created singletons through {@link singleton()}
223
	 * to avoid side-effects from stale extension information.
224
	 *
225
	 * @todo Add support for removing extensions with parameters
226
	 *
227
	 * @param string $extension class name of an {@link Extension} subclass, without parameters
228
	 */
229
	public static function remove_extension($extension) {
230
		$class = get_called_class();
231
232
		Config::inst()->remove($class, 'extensions', Config::anything(), $extension);
233
234
		// remove any instances of the extension with parameters
235
		$config = Config::inst()->get($class, 'extensions');
236
237
		if($config) {
238
			foreach($config as $k => $v) {
0 ignored issues
show
Bug introduced by
The expression $config of type array|integer|double|string|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
239
				// extensions with parameters will be stored in config as
240
				// ExtensionName("Param").
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% 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...
241
				if(preg_match(sprintf("/^(%s)\(/", preg_quote($extension, '/')), $v)) {
242
					Config::inst()->remove($class, 'extensions', Config::anything(), $v);
243
				}
244
			}
245
		}
246
247
		Config::inst()->extraConfigSourcesChanged($class);
248
249
		// unset singletons to avoid side-effects
250
		Injector::inst()->unregisterAllObjects();
251
252
		// unset some caches
253
		$subclasses = ClassInfo::subclassesFor($class);
254
		$subclasses[] = $class;
255
		if($subclasses) foreach($subclasses as $subclass) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $subclasses 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...
256
			unset(self::$classes_constructed[$subclass]);
257
			unset(self::$extra_methods[$subclass]);
258
		}
259
	}
260
261
	/**
262
	 * @param string $class
263
	 * @param bool $includeArgumentString Include the argument string in the return array,
264
	 *  FALSE would return array("Versioned"), TRUE returns array("Versioned('Stage','Live')").
265
	 * @return array Numeric array of either {@link DataExtension} class names,
266
	 *  or eval'ed class name strings with constructor arguments.
267
	 */
268
	public static function get_extensions($class, $includeArgumentString = false) {
269
		$extensions = Config::inst()->get($class, 'extensions');
270
		if(empty($extensions)) {
271
			return array();
272
		}
273
274
		// Clean nullified named extensions
275
		$extensions = array_filter(array_values($extensions));
276
277
		if($includeArgumentString) {
278
			return $extensions;
279
		} else {
280
			$extensionClassnames = array();
281
			if($extensions) foreach($extensions as $extension) {
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...
282
				$extensionClassnames[] = Extension::get_classname_without_arguments($extension);
283
			}
284
			return $extensionClassnames;
285
		}
286
	}
287
288
289
	public static function get_extra_config_sources($class = null) {
290
		if($class === null) $class = get_called_class();
291
292
		// If this class is unextendable, NOP
293
		if(in_array($class, self::$unextendable_classes)) {
294
			return null;
295
		}
296
297
		// Variable to hold sources in
298
		$sources = null;
299
300
		// Get a list of extensions
301
		$extensions = Config::inst()->get($class, 'extensions', Config::UNINHERITED | Config::EXCLUDE_EXTRA_SOURCES);
302
303
		if(!$extensions) {
304
			return null;
305
		}
306
307
		// Build a list of all sources;
308
		$sources = array();
309
310
		foreach($extensions as $extension) {
0 ignored issues
show
Bug introduced by
The expression $extensions of type array|integer|double|string|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
311
			list($extensionClass, $extensionArgs) = \Object::parse_class_spec($extension);
312
			$sources[] = $extensionClass;
313
314
			call_user_func(array($extensionClass, 'add_to_class'), $class, $extensionClass, $extensionArgs);
315
316
			foreach(array_reverse(ClassInfo::ancestry($extensionClass)) as $extensionClassParent) {
317
				if (ClassInfo::has_method_from($extensionClassParent, 'get_extra_config', $extensionClassParent)) {
318
					$extras = $extensionClassParent::get_extra_config($class, $extensionClass, $extensionArgs);
319
					if ($extras) $sources[] = $extras;
320
				}
321
			}
322
		}
323
324
		return $sources;
325
	}
326
327
328
	/**
329
	 * Return TRUE if a class has a specified extension.
330
	 * This supports backwards-compatible format (static Object::has_extension($requiredExtension))
331
	 * and new format ($object->has_extension($class, $requiredExtension))
332
	 * @param string $classOrExtension if 1 argument supplied, the class name of the extension to
333
	 *								 check for; if 2 supplied, the class name to test
334
	 * @param string $requiredExtension used only if 2 arguments supplied
335
	 * @param boolean $strict if the extension has to match the required extension and not be a subclass
336
	 * @return bool Flag if the extension exists
337
	 */
338
	public static function has_extension($classOrExtension, $requiredExtension = null, $strict = false) {
339
		//BC support
340
		if(func_num_args() > 1){
341
			$class = $classOrExtension;
342
		} else {
343
			$class = get_called_class();
344
			$requiredExtension = $classOrExtension;
345
		}
346
347
		$requiredExtension = Extension::get_classname_without_arguments($requiredExtension);
348
		$extensions = self::get_extensions($class);
349
		foreach($extensions as $extension) {
350
			if(strcasecmp($extension, $requiredExtension) === 0) {
351
				return true;
352
			}
353
			if (!$strict && is_subclass_of($extension, $requiredExtension)) {
354
				return true;
355
			}
356
		}
357
358
		return false;
359
	}
360
361
362
	/**
363
	 * Calls a method if available on both this object and all applied {@link Extensions}, and then attempts to merge
364
	 * all results into an array
365
	 *
366
	 * @param string $method the method name to call
367
	 * @param mixed $argument a single argument to pass
368
	 * @return mixed
369
	 * @todo integrate inheritance rules
370
	 */
371
	public function invokeWithExtensions($method, $argument = null) {
372
		$result = method_exists($this, $method) ? array($this->$method($argument)) : array();
373
		$extras = $this->extend($method, $argument);
374
375
		return $extras ? array_merge($result, $extras) : $result;
376
	}
377
378
	/**
379
	 * Run the given function on all of this object's extensions. Note that this method originally returned void, so if
380
	 * you wanted to return results, you're hosed
381
	 *
382
	 * Currently returns an array, with an index resulting every time the function is called. Only adds returns if
383
	 * they're not NULL, to avoid bogus results from methods just defined on the parent extension. This is important for
384
	 * permission-checks through extend, as they use min() to determine if any of the returns is FALSE. As min() doesn't
385
	 * do type checking, an included NULL return would fail the permission checks.
386
	 *
387
	 * The extension methods are defined during {@link __construct()} in {@link defineMethods()}.
388
	 *
389
	 * @param string $method the name of the method to call on each extension
390
	 * @param mixed $a1,... up to 7 arguments to be passed to the method
0 ignored issues
show
Bug introduced by
There is no parameter named $a1,.... Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
391
	 * @return array
392
	 */
393
	public function extend($method, &$a1=null, &$a2=null, &$a3=null, &$a4=null, &$a5=null, &$a6=null, &$a7=null) {
394
		$values = array();
395
396
		if(!empty($this->beforeExtendCallbacks[$method])) {
397
			foreach(array_reverse($this->beforeExtendCallbacks[$method]) as $callback) {
398
				$value = call_user_func_array($callback, array(&$a1, &$a2, &$a3, &$a4, &$a5, &$a6, &$a7));
399
				if($value !== null) $values[] = $value;
400
			}
401
			$this->beforeExtendCallbacks[$method] = array();
402
		}
403
404
		if($this->extension_instances) foreach($this->extension_instances as $instance) {
405
			if(method_exists($instance, $method)) {
406
				$instance->setOwner($this);
407
				$value = $instance->$method($a1, $a2, $a3, $a4, $a5, $a6, $a7);
408
				if($value !== null) $values[] = $value;
409
				$instance->clearOwner();
410
			}
411
		}
412
413
		if(!empty($this->afterExtendCallbacks[$method])) {
414
			foreach(array_reverse($this->afterExtendCallbacks[$method]) as $callback) {
415
				$value = call_user_func_array($callback, array(&$a1, &$a2, &$a3, &$a4, &$a5, &$a6, &$a7));
416
				if($value !== null) $values[] = $value;
417
			}
418
			$this->afterExtendCallbacks[$method] = array();
419
		}
420
421
		return $values;
422
	}
423
424
	/**
425
	 * Get an extension instance attached to this object by name.
426
	 *
427
	 * @uses hasExtension()
428
	 *
429
	 * @param string $extension
430
	 * @return Extension
431
	 */
432
	public function getExtensionInstance($extension) {
433
		if($this->hasExtension($extension)) return $this->extension_instances[$extension];
434
	}
435
436
	/**
437
	 * Returns TRUE if this object instance has a specific extension applied
438
	 * in {@link $extension_instances}. Extension instances are initialized
439
	 * at constructor time, meaning if you use {@link add_extension()}
440
	 * afterwards, the added extension will just be added to new instances
441
	 * of the extended class. Use the static method {@link has_extension()}
442
	 * to check if a class (not an instance) has a specific extension.
443
	 * Caution: Don't use singleton(<class>)->hasExtension() as it will
444
	 * give you inconsistent results based on when the singleton was first
445
	 * accessed.
446
	 *
447
	 * @param string $extension Classname of an {@link Extension} subclass without parameters
448
	 * @return bool
449
	 */
450
	public function hasExtension($extension) {
451
		return isset($this->extension_instances[$extension]);
452
	}
453
454
	/**
455
	 * Get all extension instances for this specific object instance.
456
	 * See {@link get_extensions()} to get all applied extension classes
457
	 * for this class (not the instance).
458
	 *
459
	 * @return array Map of {@link DataExtension} instances, keyed by classname.
460
	 */
461
	public function getExtensionInstances() {
462
		return $this->extension_instances;
463
	}
464
465
}