Completed
Push — master ( a6ff96...8afff1 )
by Daniel
10:22
created

ChangeSet   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 495
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 15

Importance

Changes 0
Metric Value
dl 0
loc 495
rs 4.8387
c 0
b 0
f 0
wmc 58
lcom 1
cbo 15

17 Methods

Rating   Name   Duplication   Size   Complexity  
B publish() 0 36 6
B addObject() 0 26 3
A removeObject() 0 15 2
A implicitKey() 0 6 2
B calculateImplicit() 0 45 5
B sync() 0 35 4
A isSynced() 0 17 3
A canView() 0 3 1
A canEdit() 0 3 1
A canCreate() 0 3 1
A canDelete() 0 3 1
A canPublish() 0 12 3
A canRevert() 0 12 3
A can() 0 15 3
A getCMSFields() 0 13 3
F getDescription() 0 107 16
A fieldLabels() 0 7 1

How to fix   Complexity   

Complex Class

Complex classes like ChangeSet often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ChangeSet, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\ORM\Versioning;
4
5
use SilverStripe\Forms\FieldList;
6
use SilverStripe\Forms\TabSet;
7
use SilverStripe\Forms\TextField;
8
use SilverStripe\Forms\ReadonlyField;
9
use SilverStripe\i18n\i18n;
10
use SilverStripe\ORM\HasManyList;
11
use SilverStripe\ORM\ValidationException;
12
use SilverStripe\ORM\DB;
13
use SilverStripe\ORM\DataObject;
14
use SilverStripe\ORM\UnexpectedDataException;
15
use SilverStripe\Security\Member;
16
use SilverStripe\Security\Permission;
17
use BadMethodCallException;
18
use Exception;
19
use LogicException;
20
21
/**
22
 * The ChangeSet model tracks several VersionedAndStaged objects for later publication as a single
23
 * atomic action
24
 *
25
 * @method HasManyList Changes()
26
 * @method Member Owner()
27
 * @property string $Name
28
 * @property string $State
29
 * @property bool $IsInferred
30
 */
31
class ChangeSet extends DataObject {
32
33
	private static $singular_name = 'Campaign';
34
35
	private static $plural_name = 'Campaigns';
36
37
	/** An active changeset */
38
	const STATE_OPEN = 'open';
39
40
	/** A changeset which is reverted and closed */
41
	const STATE_REVERTED = 'reverted';
42
43
	/** A changeset which is published and closed */
44
	const STATE_PUBLISHED = 'published';
45
46
	private static $table_name = 'ChangeSet';
47
48
	private static $db = array(
49
		'Name'  => 'Varchar',
50
		'State' => "Enum('open,published,reverted','open')",
51
		'IsInferred' => 'Boolean(0)' // True if created automatically
52
	);
53
54
	private static $has_many = array(
55
		'Changes' => 'SilverStripe\ORM\Versioning\ChangeSetItem',
56
	);
57
58
	private static $defaults = array(
59
		'State' => 'open'
60
	);
61
62
	private static $has_one = array(
63
		'Owner' => 'SilverStripe\\Security\\Member',
64
	);
65
66
	private static $casting = array(
67
		'Description' => 'Text',
68
	);
69
70
	/**
71
	 * List of classes to set apart in description
72
	 *
73
	 * @config
74
	 * @var array
75
	 */
76
	private static $important_classes = array(
77
		'SilverStripe\\CMS\\Model\\SiteTree',
78
		'SilverStripe\\Assets\\File',
79
	);
80
81
	/**
82
	 * Default permission to require for publishers.
83
	 * Publishers must either be able to use the campaign admin, or have all admin access.
84
	 *
85
	 * Also used as default permission for ChangeSetItem default permission.
86
	 *
87
	 * @config
88
	 * @var array
89
	 */
90
	private static $required_permission = array('CMS_ACCESS_CampaignAdmin', 'CMS_ACCESS_LeftAndMain');
91
92
	/**
93
	 * Publish this changeset, then closes it.
94
	 *
95
	 * @throws Exception
96
	 * @return bool True if successful
97
	 */
98
	public function publish() {
99
		// Logical checks prior to publish
100
		if($this->State !== static::STATE_OPEN) {
101
			throw new BadMethodCallException(
102
				"ChangeSet can't be published if it has been already published or reverted."
103
			);
104
		}
105
		if(!$this->isSynced()) {
106
			throw new ValidationException(
107
				"ChangeSet does not include all necessary changes and cannot be published."
108
			);
109
		}
110
		if(!$this->canPublish()) {
111
			throw new LogicException("The current member does not have permission to publish this ChangeSet.");
112
		}
113
114
		DB::get_conn()->withTransaction(function(){
115
			foreach($this->Changes() as $change) {
116
				/** @var ChangeSetItem $change */
117
				$change->publish();
118
			}
119
120
			// Once this changeset is published, unlink any objects linking to
121
			// records in this changeset as unlinked (set RelationID to 0).
122
			// This is done as a safer alternative to deleting records on live that
123
			// are deleted on stage.
124
			foreach($this->Changes() as $change) {
125
				/** @var ChangeSetItem $change */
126
				$change->unlinkDisownedObjects();
127
			}
128
129
			$this->State = static::STATE_PUBLISHED;
130
			$this->write();
131
		});
132
		return true;
133
	}
134
135
	/**
136
	 * Add a new change to this changeset. Will automatically include all owned
137
	 * changes as those are dependencies of this item.
138
	 *
139
	 * @param DataObject $object
140
	 */
141
	public function addObject(DataObject $object) {
142
		if(!$this->isInDB()) {
143
			throw new BadMethodCallException("ChangeSet must be saved before adding items");
144
		}
145
146
		$references = [
147
			'ObjectID'    => $object->ID,
148
			'ObjectClass' => $object->baseClass(),
149
		];
150
151
		// Get existing item in case already added
152
		$item = $this->Changes()->filter($references)->first();
153
154
		if (!$item) {
155
			$item = new ChangeSetItem($references);
156
			$this->Changes()->add($item);
157
		}
158
159
		$item->ReferencedBy()->removeAll();
160
161
		$item->Added = ChangeSetItem::EXPLICITLY;
0 ignored issues
show
Documentation introduced by
The property Added does not exist on object<SilverStripe\ORM\DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
162
		$item->write();
163
164
165
		$this->sync();
166
	}
167
168
	/**
169
	 * Remove an item from this changeset. Will automatically remove all changes
170
	 * which own (and thus depend on) the removed item.
171
	 *
172
	 * @param DataObject $object
173
	 */
174
	public function removeObject(DataObject $object) {
175
		$item = ChangeSetItem::get()->filter([
176
				'ObjectID' => $object->ID,
177
				'ObjectClass' => $object->baseClass(),
178
				'ChangeSetID' => $this->ID
179
			])->first();
180
181
		if ($item) {
182
			// TODO: Handle case of implicit added item being removed.
183
184
			$item->delete();
185
		}
186
187
		$this->sync();
188
	}
189
190
	/**
191
	 * Build identifying string key for this object
192
	 *
193
	 * @param DataObject $item
194
	 * @return string
195
	 */
196
	protected function implicitKey(DataObject $item) {
197
		if ($item instanceof ChangeSetItem) {
198
			return $item->ObjectClass.'.'.$item->ObjectID;
199
		}
200
		return $item->baseClass().'.'.$item->ID;
201
	}
202
203
	protected function calculateImplicit() {
204
		/** @var string[][] $explicit List of all items that have been explicitly added to this ChangeSet */
205
		$explicit = array();
206
207
		/** @var string[][] $referenced List of all items that are "referenced" by items in $explicit */
208
		$referenced = array();
209
210
		/** @var string[][] $references List of which explicit items reference each thing in referenced */
211
		$references = array();
212
213
		/** @var ChangeSetItem $item */
214
		foreach ($this->Changes()->filter(['Added' => ChangeSetItem::EXPLICITLY]) as $item) {
215
			$explicitKey = $this->implicitKey($item);
216
			$explicit[$explicitKey] = true;
217
218
			foreach ($item->findReferenced() as $referee) {
219
				try {
220
					/** @var DataObject $referee */
221
					$key = $this->implicitKey($referee);
222
223
					$referenced[$key] = [
224
						'ObjectID' => $referee->ID,
225
						'ObjectClass' => $referee->baseClass(),
226
					];
227
228
					$references[$key][] = $item->ID;
229
230
				// Skip any bad records
231
				} catch(UnexpectedDataException $e) {
232
				}
233
			}
234
		}
235
236
		/** @var string[][] $explicit List of all items that are either in $explicit, $referenced or both */
237
		$all = array_merge($referenced, $explicit);
238
239
		/** @var string[][] $implicit Anything that is in $all, but not in $explicit, is an implicit inclusion */
240
		$implicit = array_diff_key($all, $explicit);
241
242
		foreach($implicit as $key => $object) {
243
			$implicit[$key]['ReferencedBy'] = $references[$key];
244
		}
245
246
		return $implicit;
247
	}
248
249
	/**
250
	 * Add implicit changes that should be included in this changeset
251
	 *
252
	 * When an item is created or changed, all it's owned items which have
253
	 * changes are implicitly added
254
	 *
255
	 * When an item is deleted, it's owner (even if that owner does not have changes)
256
	 * is implicitly added
257
	 */
258
	public function sync() {
259
		// Start a transaction (if we can)
260
		DB::get_conn()->withTransaction(function() {
261
262
			// Get the implicitly included items for this ChangeSet
263
			$implicit = $this->calculateImplicit();
264
265
			// Adjust the existing implicit ChangeSetItems for this ChangeSet
266
			/** @var ChangeSetItem $item */
267
			foreach ($this->Changes()->filter(['Added' => ChangeSetItem::IMPLICITLY]) as $item) {
268
				$objectKey = $this->implicitKey($item);
269
270
				// If a ChangeSetItem exists, but isn't in $implicit, it's no longer required, so delete it
271
				if (!array_key_exists($objectKey, $implicit)) {
272
					$item->delete();
273
				}
274
				// Otherwise it is required, so update ReferencedBy and remove from $implicit
275
				else {
276
					$item->ReferencedBy()->setByIDList($implicit[$objectKey]['ReferencedBy']);
277
					unset($implicit[$objectKey]);
278
				}
279
			}
280
281
			// Now $implicit is all those items that are implicitly included, but don't currently have a ChangeSetItem.
282
			// So create new ChangeSetItems to match
283
284
			foreach ($implicit as $key => $props) {
285
				$item = new ChangeSetItem($props);
286
				$item->Added = ChangeSetItem::IMPLICITLY;
287
				$item->ChangeSetID = $this->ID;
0 ignored issues
show
Documentation introduced by
The property ChangeSetID does not exist on object<SilverStripe\ORM\Versioning\ChangeSetItem>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
288
				$item->ReferencedBy()->setByIDList($props['ReferencedBy']);
289
				$item->write();
290
			}
291
		});
292
	}
293
294
	/** Verify that any objects in this changeset include all owned changes */
295
	public function isSynced() {
296
		$implicit = $this->calculateImplicit();
297
298
		// Check the existing implicit ChangeSetItems for this ChangeSet
299
300
		foreach ($this->Changes()->filter(['Added' => ChangeSetItem::IMPLICITLY]) as $item) {
301
			$objectKey = $this->implicitKey($item);
302
303
			// If a ChangeSetItem exists, but isn't in $implicit -> validation failure
304
			if (!array_key_exists($objectKey, $implicit)) return false;
305
			// Exists, remove from $implicit
306
			unset($implicit[$objectKey]);
307
		}
308
309
		// If there's anything left in $implicit -> validation failure
310
		return empty($implicit);
311
	}
312
313
	public function canView($member = null) {
314
		return $this->can(__FUNCTION__, $member);
315
	}
316
317
	public function canEdit($member = null) {
318
		return $this->can(__FUNCTION__, $member);
319
	}
320
321
	public function canCreate($member = null, $context = array()) {
322
		return $this->can(__FUNCTION__, $member, $context);
323
	}
324
325
	public function canDelete($member = null) {
326
		return $this->can(__FUNCTION__, $member);
327
	}
328
329
	/**
330
	 * Check if this item is allowed to be published
331
	 *
332
	 * @param Member $member
333
	 * @return bool
334
	 */
335
	public function canPublish($member = null) {
336
		// All changes must be publishable
337
		foreach($this->Changes() as $change) {
338
			/** @var ChangeSetItem $change */
339
			if(!$change->canPublish($member)) {
340
				return false;
341
			}
342
		}
343
344
		// Default permission
345
		return $this->can(__FUNCTION__, $member);
346
	}
347
348
	/**
349
	 * Check if this changeset (if published) can be reverted
350
	 *
351
	 * @param Member $member
352
	 * @return bool
353
	 */
354
	public function canRevert($member = null) {
355
		// All changes must be publishable
356
		foreach($this->Changes() as $change) {
357
			/** @var ChangeSetItem $change */
358
			if(!$change->canRevert($member)) {
0 ignored issues
show
Bug introduced by
It seems like $member defined by parameter $member on line 354 can be null; however, SilverStripe\ORM\Version...ngeSetItem::canRevert() does not accept null, maybe add an additional type check?

It seems like you allow that null is being passed for a parameter, however the function which is called does not seem to accept null.

We recommend to add an additional type check (or disallow null for the parameter):

function notNullable(stdClass $x) { }

// Unsafe
function withoutCheck(stdClass $x = null) {
    notNullable($x);
}

// Safe - Alternative 1: Adding Additional Type-Check
function withCheck(stdClass $x = null) {
    if ($x instanceof stdClass) {
        notNullable($x);
    }
}

// Safe - Alternative 2: Changing Parameter
function withNonNullableParam(stdClass $x) {
    notNullable($x);
}
Loading history...
359
				return false;
360
			}
361
		}
362
363
		// Default permission
364
		return $this->can(__FUNCTION__, $member);
365
	}
366
367
	/**
368
	 * Default permissions for this changeset
369
	 *
370
	 * @param string $perm
371
	 * @param Member $member
372
	 * @param array $context
373
	 * @return bool
374
	 */
375
	public function can($perm, $member = null, $context = array()) {
376
		if(!$member) {
377
			$member = Member::currentUser();
378
		}
379
380
		// Allow extensions to bypass default permissions, but only if
381
		// each change can be individually published.
382
		$extended = $this->extendedCan($perm, $member, $context);
0 ignored issues
show
Bug introduced by
It seems like $member can be null; however, extendedCan() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
383
		if($extended !== null) {
384
			return $extended;
385
		}
386
387
		// Default permissions
388
		return (bool)Permission::checkMember($member, $this->config()->required_permission);
389
	}
390
391
	public function getCMSFields() {
392
		$fields = new FieldList(new TabSet('Root'));
393
		if ($this->IsInferred) {
394
			$fields->addFieldToTab('Root.Main', ReadonlyField::create('Name', $this->fieldLabel('Name')));
395
		} else {
396
			$fields->addFieldToTab('Root.Main', TextField::create('Name', $this->fieldLabel('Name')));
397
		}
398
		if ($this->isInDB()) {
399
			$fields->addFieldToTab('Root.Main', ReadonlyField::create('State', $this->fieldLabel('State')));
400
		}
401
		$this->extend('updateCMSFields', $fields);
402
		return $fields;
403
	}
404
405
	/**
406
	 * Gets summary of items in changeset
407
	 *
408
	 * @return string
409
	 */
410
	public function getDescription() {
411
		// Initialise list of items to count
412
		$counted = [];
413
		$countedOther = 0;
414
		foreach($this->config()->important_classes as $type) {
415
			if(class_exists($type)) {
416
				$counted[$type] = 0;
417
			}
418
		}
419
420
		// Check each change item
421
		/** @var ChangeSetItem $change */
422
		foreach($this->Changes() as $change) {
423
			$found = false;
424
			foreach($counted as $class => $num) {
425
				if(is_a($change->ObjectClass, $class, true)) {
426
					$counted[$class]++;
427
					$found = true;
428
					break;
429
				}
430
			}
431
			if(!$found) {
432
				$countedOther++;
433
			}
434
		}
435
436
		// Describe set based on this output
437
		$counted = array_filter($counted);
438
439
		// Empty state
440
		if(empty($counted) && empty($countedOther)) {
441
			return '';
442
		}
443
444
		// Put all parts together
445
		$parts = [];
446
		foreach($counted as $class => $count) {
447
			$parts[] = DataObject::singleton($class)->i18n_pluralise($count);
448
		}
449
450
		// Describe non-important items
451
		if($countedOther) {
452
			if ($counted) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $counted 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...
453
				$parts[] = i18n::pluralise(
454
					_t('ChangeSet.DESCRIPTION_OTHER_ITEM', 'other item'),
455
					_t('ChangeSet.DESCRIPTION_OTHER_ITEMS', 'other items'),
456
					$countedOther
457
				);
458
			} else {
459
				$parts[] = i18n::pluralise(
460
					_t('ChangeSet.DESCRIPTION_ITEM', 'item'),
461
					_t('ChangeSet.DESCRIPTION_ITEMS', 'items'),
462
					$countedOther
463
				);
464
			}
465
		}
466
467
		// Figure out how to join everything together
468
		if(empty($parts)) {
469
			return '';
470
		}
471
		if(count($parts) === 1) {
472
			return $parts[0];
473
		}
474
475
		// Non-comma list
476
		if(count($parts) === 2) {
477
			return _t(
478
				'ChangeSet.DESCRIPTION_AND',
479
				'{first} and {second}',
480
				[
481
					'first' => $parts[0],
482
					'second' => $parts[1],
483
				]
484
			);
485
		}
486
487
		// First item
488
		$string = _t(
489
			'ChangeSet.DESCRIPTION_LIST_FIRST',
490
			'{item}',
491
			['item' => $parts[0]]
492
		);
493
494
		// Middle items
495
		for($i = 1; $i < count($parts) - 1; $i++) {
496
			$string = _t(
497
				'ChangeSet.DESCRIPTION_LIST_MID',
498
				'{list}, {item}',
499
				[
500
					'list' => $string,
501
					'item' => $parts[$i]
502
				]
503
			);
504
		}
505
506
		// Oxford comma
507
		$string = _t(
508
			'ChangeSet.DESCRIPTION_LIST_LAST',
509
			'{list}, and {item}',
510
			[
511
				'list' => $string,
512
				'item' => end($parts)
513
			]
514
		);
515
		return $string;
516
	}
517
518
	public function fieldLabels($includerelations = true) {
519
		$labels = parent::fieldLabels($includerelations);
520
		$labels['Name'] = _t('ChangeSet.NAME', 'Name');
521
		$labels['State'] = _t('ChangeSet.STATE', 'State');
522
523
		return $labels;
524
	}
525
}
526