Completed
Push — master ( 955d75...4d8fb1 )
by Ingo
36:25 queued 24:15
created

ChangeSet::calculateImplicit()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 40
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 18
nc 6
nop 0
dl 0
loc 40
rs 8.5806
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\ORM\Versioning;
4
5
use SilverStripe\Forms\FieldList;
6
use SilverStripe\Forms\TextField;
7
use SilverStripe\Forms\ReadonlyField;
8
use SilverStripe\i18n\i18n;
9
use SilverStripe\ORM\HasManyList;
10
use SilverStripe\ORM\ValidationException;
11
use SilverStripe\ORM\DB;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\Security\Member;
14
use SilverStripe\Security\Permission;
15
use BadMethodCallException;
16
use Exception;
17
use LogicException;
18
19
/**
20
 * The ChangeSet model tracks several VersionedAndStaged objects for later publication as a single
21
 * atomic action
22
 *
23
 * @method HasManyList Changes()
24
 * @method Member Owner()
25
 * @property string $Name
26
 * @property string $State
27
 */
28
class ChangeSet extends DataObject {
29
30
	private static $singular_name = 'Campaign';
31
32
	private static $plural_name = 'Campaigns';
33
34
	/** An active changeset */
35
	const STATE_OPEN = 'open';
36
37
	/** A changeset which is reverted and closed */
38
	const STATE_REVERTED = 'reverted';
39
40
	/** A changeset which is published and closed */
41
	const STATE_PUBLISHED = 'published';
42
43
	private static $table_name = 'ChangeSet';
44
45
	private static $db = array(
46
		'Name'  => 'Varchar',
47
		'State' => "Enum('open,published,reverted','open')",
48
	);
49
50
	private static $has_many = array(
51
		'Changes' => 'SilverStripe\ORM\Versioning\ChangeSetItem',
52
	);
53
54
	private static $defaults = array(
55
		'State' => 'open'
56
	);
57
58
	private static $has_one = array(
59
		'Owner' => 'SilverStripe\\Security\\Member',
60
	);
61
62
	private static $casting = array(
63
		'Description' => 'Text',
64
	);
65
66
	/**
67
	 * List of classes to set apart in description
68
	 *
69
	 * @config
70
	 * @var array
71
	 */
72
	private static $important_classes = array(
73
		'SilverStripe\\CMS\\Model\\SiteTree',
74
		'SilverStripe\\Assets\\File',
75
	);
76
77
	/**
78
	 * Default permission to require for publishers.
79
	 * Publishers must either be able to use the campaign admin, or have all admin access.
80
	 *
81
	 * Also used as default permission for ChangeSetItem default permission.
82
	 *
83
	 * @config
84
	 * @var array
85
	 */
86
	private static $required_permission = array('CMS_ACCESS_CampaignAdmin', 'CMS_ACCESS_LeftAndMain');
87
88
	/**
89
	 * Publish this changeset, then closes it.
90
	 *
91
	 * @throws Exception
92
	 */
93
	public function publish() {
94
		// Logical checks prior to publish
95
		if($this->State !== static::STATE_OPEN) {
96
			throw new BadMethodCallException(
97
				"ChangeSet can't be published if it has been already published or reverted."
98
			);
99
		}
100
		if(!$this->isSynced()) {
101
			throw new ValidationException(
102
				"ChangeSet does not include all necessary changes and cannot be published."
103
			);
104
		}
105
		if(!$this->canPublish()) {
106
			throw new LogicException("The current member does not have permission to publish this ChangeSet.");
107
		}
108
109
		DB::get_conn()->withTransaction(function(){
110
			foreach($this->Changes() as $change) {
111
				/** @var ChangeSetItem $change */
112
				$change->publish();
113
			}
114
115
			$this->State = static::STATE_PUBLISHED;
116
			$this->write();
117
		});
118
	}
119
120
	/**
121
	 * Add a new change to this changeset. Will automatically include all owned
122
	 * changes as those are dependencies of this item.
123
	 *
124
	 * @param DataObject $object
125
	 */
126
	public function addObject(DataObject $object) {
127
		if(!$this->isInDB()) {
128
			throw new BadMethodCallException("ChangeSet must be saved before adding items");
129
		}
130
131
		$references = [
132
			'ObjectID'    => $object->ID,
133
			'ObjectClass' => $object->baseClass(),
134
		];
135
136
		// Get existing item in case already added
137
		$item = $this->Changes()->filter($references)->first();
138
139
		if (!$item) {
140
			$item = new ChangeSetItem($references);
141
			$this->Changes()->add($item);
142
		}
143
144
		$item->ReferencedBy()->removeAll();
145
146
		$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...
147
		$item->write();
148
149
150
		$this->sync();
151
	}
152
153
	/**
154
	 * Remove an item from this changeset. Will automatically remove all changes
155
	 * which own (and thus depend on) the removed item.
156
	 *
157
	 * @param DataObject $object
158
	 */
159
	public function removeObject(DataObject $object) {
160
		$item = ChangeSetItem::get()->filter([
161
				'ObjectID' => $object->ID,
162
				'ObjectClass' => $object->baseClass(),
163
				'ChangeSetID' => $this->ID
164
			])->first();
165
166
		if ($item) {
167
			// TODO: Handle case of implicit added item being removed.
168
169
			$item->delete();
170
		}
171
172
		$this->sync();
173
	}
174
175
	/**
176
	 * Build identifying string key for this object
177
	 *
178
	 * @param DataObject $item
179
	 * @return string
180
	 */
181
	protected function implicitKey(DataObject $item) {
182
		if ($item instanceof ChangeSetItem) {
183
			return $item->ObjectClass.'.'.$item->ObjectID;
184
		}
185
		return $item->baseClass().'.'.$item->ID;
186
	}
187
188
	protected function calculateImplicit() {
189
		/** @var string[][] $explicit List of all items that have been explicitly added to this ChangeSet */
190
		$explicit = array();
191
192
		/** @var string[][] $referenced List of all items that are "referenced" by items in $explicit */
193
		$referenced = array();
194
195
		/** @var string[][] $references List of which explicit items reference each thing in referenced */
196
		$references = array();
197
198
		/** @var ChangeSetItem $item */
199
		foreach ($this->Changes()->filter(['Added' => ChangeSetItem::EXPLICITLY]) as $item) {
200
			$explicitKey = $this->implicitKey($item);
201
			$explicit[$explicitKey] = true;
202
203
			foreach ($item->findReferenced() as $referee) {
204
				/** @var DataObject $referee */
205
				$key = $this->implicitKey($referee);
206
207
				$referenced[$key] = [
208
					'ObjectID' => $referee->ID,
209
					'ObjectClass' => $referee->baseClass(),
210
				];
211
212
				$references[$key][] = $item->ID;
213
			}
214
		}
215
216
		/** @var string[][] $explicit List of all items that are either in $explicit, $referenced or both */
217
		$all = array_merge($referenced, $explicit);
218
219
		/** @var string[][] $implicit Anything that is in $all, but not in $explicit, is an implicit inclusion */
220
		$implicit = array_diff_key($all, $explicit);
221
222
		foreach($implicit as $key => $object) {
223
			$implicit[$key]['ReferencedBy'] = $references[$key];
224
		}
225
226
		return $implicit;
227
	}
228
229
	/**
230
	 * Add implicit changes that should be included in this changeset
231
	 *
232
	 * When an item is created or changed, all it's owned items which have
233
	 * changes are implicitly added
234
	 *
235
	 * When an item is deleted, it's owner (even if that owner does not have changes)
236
	 * is implicitly added
237
	 */
238
	public function sync() {
239
		// Start a transaction (if we can)
240
		DB::get_conn()->withTransaction(function() {
241
242
			// Get the implicitly included items for this ChangeSet
243
			$implicit = $this->calculateImplicit();
244
245
			// Adjust the existing implicit ChangeSetItems for this ChangeSet
246
			/** @var ChangeSetItem $item */
247
			foreach ($this->Changes()->filter(['Added' => ChangeSetItem::IMPLICITLY]) as $item) {
248
				$objectKey = $this->implicitKey($item);
249
250
				// If a ChangeSetItem exists, but isn't in $implicit, it's no longer required, so delete it
251
				if (!array_key_exists($objectKey, $implicit)) {
252
					$item->delete();
253
				}
254
				// Otherwise it is required, so update ReferencedBy and remove from $implicit
255
				else {
256
					$item->ReferencedBy()->setByIDList($implicit[$objectKey]['ReferencedBy']);
257
					unset($implicit[$objectKey]);
258
				}
259
			}
260
261
			// Now $implicit is all those items that are implicitly included, but don't currently have a ChangeSetItem.
262
			// So create new ChangeSetItems to match
263
264
			foreach ($implicit as $key => $props) {
265
				$item = new ChangeSetItem($props);
266
				$item->Added = ChangeSetItem::IMPLICITLY;
267
				$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...
268
				$item->ReferencedBy()->setByIDList($props['ReferencedBy']);
269
				$item->write();
270
			}
271
		});
272
	}
273
274
	/** Verify that any objects in this changeset include all owned changes */
275
	public function isSynced() {
276
		$implicit = $this->calculateImplicit();
277
278
		// Check the existing implicit ChangeSetItems for this ChangeSet
279
280
		foreach ($this->Changes()->filter(['Added' => ChangeSetItem::IMPLICITLY]) as $item) {
281
			$objectKey = $this->implicitKey($item);
282
283
			// If a ChangeSetItem exists, but isn't in $implicit -> validation failure
284
			if (!array_key_exists($objectKey, $implicit)) return false;
285
			// Exists, remove from $implicit
286
			unset($implicit[$objectKey]);
287
		}
288
289
		// If there's anything left in $implicit -> validation failure
290
		return empty($implicit);
291
	}
292
293
	public function canView($member = null) {
294
		return $this->can(__FUNCTION__, $member);
295
	}
296
297
	public function canEdit($member = null) {
298
		return $this->can(__FUNCTION__, $member);
299
	}
300
301
	public function canCreate($member = null, $context = array()) {
302
		return $this->can(__FUNCTION__, $member, $context);
303
	}
304
305
	public function canDelete($member = null) {
306
		return $this->can(__FUNCTION__, $member);
307
	}
308
309
	/**
310
	 * Check if this item is allowed to be published
311
	 *
312
	 * @param Member $member
313
	 * @return bool
314
	 */
315
	public function canPublish($member = null) {
316
		// All changes must be publishable
317
		foreach($this->Changes() as $change) {
318
			/** @var ChangeSetItem $change */
319
			if(!$change->canPublish($member)) {
320
				return false;
321
			}
322
		}
323
324
		// Default permission
325
		return $this->can(__FUNCTION__, $member);
326
	}
327
328
	/**
329
	 * Check if this changeset (if published) can be reverted
330
	 *
331
	 * @param Member $member
332
	 * @return bool
333
	 */
334
	public function canRevert($member = null) {
335
		// All changes must be publishable
336
		foreach($this->Changes() as $change) {
337
			/** @var ChangeSetItem $change */
338
			if(!$change->canRevert($member)) {
0 ignored issues
show
Bug introduced by
It seems like $member defined by parameter $member on line 334 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...
339
				return false;
340
			}
341
		}
342
343
		// Default permission
344
		return $this->can(__FUNCTION__, $member);
345
	}
346
347
	/**
348
	 * Default permissions for this changeset
349
	 *
350
	 * @param string $perm
351
	 * @param Member $member
352
	 * @param array $context
353
	 * @return bool
354
	 */
355
	public function can($perm, $member = null, $context = array()) {
356
		if(!$member) {
357
			$member = Member::currentUser();
358
		}
359
360
		// Allow extensions to bypass default permissions, but only if
361
		// each change can be individually published.
362
		$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...
363
		if($extended !== null) {
364
			return $extended;
365
		}
366
367
		// Default permissions
368
		return (bool)Permission::checkMember($member, $this->config()->required_permission);
369
	}
370
371
	public function getCMSFields() {
372
		$fields = parent::getCMSFields();
373
374
		$fields->removeByName('OwnerID');
375
		$fields->removeByName('Changes');
376
377
		$fields->dataFieldByName('State')->setReadonly(true);
378
379
		$this->extend('updateCMSFields', $fields);
380
		return $fields;
381
	}
382
383
	/**
384
	 * Gets summary of items in changeset
385
	 *
386
	 * @return string
387
	 */
388
	public function getDescription() {
389
		// Initialise list of items to count
390
		$counted = [];
391
		$countedOther = 0;
392
		foreach($this->config()->important_classes as $type) {
393
			if(class_exists($type)) {
394
				$counted[$type] = 0;
395
			}
396
		}
397
398
		// Check each change item
399
		/** @var ChangeSetItem $change */
400
		foreach($this->Changes() as $change) {
401
			$found = false;
402
			foreach($counted as $class => $num) {
403
				if(is_a($change->ObjectClass, $class, true)) {
404
					$counted[$class]++;
405
					$found = true;
406
					break;
407
				}
408
			}
409
			if(!$found) {
410
				$countedOther++;
411
			}
412
		}
413
414
		// Describe set based on this output
415
		$counted = array_filter($counted);
416
417
		// Empty state
418
		if(empty($counted) && empty($countedOther)) {
419
			return '';
420
		}
421
422
		// Put all parts together
423
		$parts = [];
424
		foreach($counted as $class => $count) {
425
			$parts[] = DataObject::singleton($class)->i18n_pluralise($count);
426
		}
427
428
		// Describe non-important items
429
		if($countedOther) {
430
			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...
431
				$parts[] = i18n::pluralise(
432
					_t('ChangeSet.DESCRIPTION_OTHER_ITEM', 'other item'),
433
					_t('ChangeSet.DESCRIPTION_OTHER_ITEMS', 'other items'),
434
					$countedOther
435
				);
436
			} else {
437
				$parts[] = i18n::pluralise(
438
					_t('ChangeSet.DESCRIPTION_ITEM', 'item'),
439
					_t('ChangeSet.DESCRIPTION_ITEMS', 'items'),
440
					$countedOther
441
				);
442
			}
443
		}
444
445
		// Figure out how to join everything together
446
		if(empty($parts)) {
447
			return '';
448
		}
449
		if(count($parts) === 1) {
450
			return $parts[0];
451
		}
452
453
		// Non-comma list
454
		if(count($parts) === 2) {
455
			return _t(
456
				'ChangeSet.DESCRIPTION_AND',
457
				'{first} and {second}',
458
				[
459
					'first' => $parts[0],
460
					'second' => $parts[1],
461
				]
462
			);
463
		}
464
465
		// First item
466
		$string = _t(
467
			'ChangeSet.DESCRIPTION_LIST_FIRST',
468
			'{item}',
469
			['item' => $parts[0]]
470
		);
471
472
		// Middle items
473
		for($i = 1; $i < count($parts) - 1; $i++) {
474
			$string = _t(
475
				'ChangeSet.DESCRIPTION_LIST_MID',
476
				'{list}, {item}',
477
				[
478
					'list' => $string,
479
					'item' => $parts[$i]
480
				]
481
			);
482
		}
483
484
		// Oxford comma
485
		$string = _t(
486
			'ChangeSet.DESCRIPTION_LIST_LAST',
487
			'{list}, and {item}',
488
			[
489
				'list' => $string,
490
				'item' => end($parts)
491
			]
492
		);
493
		return $string;
494
	}
495
496
	public function fieldLabels($includerelations = true) {
497
		$labels = parent::fieldLabels($includerelations);
498
		$labels['Name'] = _t('ChangeSet.NAME', 'Name');
499
		$labels['State'] = _t('ChangeSet.STATE', 'State');
500
501
		return $labels;
502
	}
503
}
504