Completed
Push — master ( e01846...7c0007 )
by Damian
35s
created

ChangeSet::fieldLabels()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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