Completed
Push — master ( b0e0e9...f4bf0c )
by Will
08:32
created

ChangeSet   F

Complexity

Total Complexity 63

Size/Duplication

Total Lines 549
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 15

Importance

Changes 1
Bugs 0 Features 0
Metric Value
dl 0
loc 549
rs 3.6585
c 1
b 0
f 0
wmc 63
lcom 1
cbo 15

19 Methods

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