Completed
Push — master ( 2846af...322d3e )
by Ingo
12:00
created

ChangeSet::fieldLabels()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 1
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
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
        $references = [
156
            'ObjectID'    => $object->ID,
157
            'ObjectClass' => $object->baseClass(),
158
        ];
159
160
        // Get existing item in case already added
161
        $item = $this->Changes()->filter($references)->first();
162
163
        if (!$item) {
164
            $item = new ChangeSetItem($references);
165
            $this->Changes()->add($item);
166
        }
167
168
        $item->ReferencedBy()->removeAll();
169
170
        $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...
171
        $item->write();
172
173
174
        $this->sync();
175
    }
176
177
    /**
178
     * Remove an item from this changeset. Will automatically remove all changes
179
     * which own (and thus depend on) the removed item.
180
     *
181
     * @param DataObject $object
182
     */
183
    public function removeObject(DataObject $object)
184
    {
185
        $item = ChangeSetItem::get()->filter([
186
                'ObjectID' => $object->ID,
187
                'ObjectClass' => $object->baseClass(),
188
                'ChangeSetID' => $this->ID
189
            ])->first();
190
191
        if ($item) {
192
            // TODO: Handle case of implicit added item being removed.
193
194
            $item->delete();
195
        }
196
197
        $this->sync();
198
    }
199
200
    /**
201
     * Build identifying string key for this object
202
     *
203
     * @param DataObject $item
204
     * @return string
205
     */
206
    protected function implicitKey(DataObject $item)
207
    {
208
        if ($item instanceof ChangeSetItem) {
209
            return $item->ObjectClass.'.'.$item->ObjectID;
210
        }
211
        return $item->baseClass().'.'.$item->ID;
212
    }
213
214
    protected function calculateImplicit()
215
    {
216
        /** @var string[][] $explicit List of all items that have been explicitly added to this ChangeSet */
217
        $explicit = array();
218
219
        /** @var string[][] $referenced List of all items that are "referenced" by items in $explicit */
220
        $referenced = array();
221
222
        /** @var string[][] $references List of which explicit items reference each thing in referenced */
223
        $references = array();
224
225
        /** @var ChangeSetItem $item */
226
        foreach ($this->Changes()->filter(['Added' => ChangeSetItem::EXPLICITLY]) as $item) {
227
            $explicitKey = $this->implicitKey($item);
228
            $explicit[$explicitKey] = true;
229
230
            foreach ($item->findReferenced() as $referee) {
231
                try {
232
                    /** @var DataObject $referee */
233
                    $key = $this->implicitKey($referee);
234
235
                    $referenced[$key] = [
236
                        'ObjectID' => $referee->ID,
237
                        'ObjectClass' => $referee->baseClass(),
238
                    ];
239
240
                    $references[$key][] = $item->ID;
241
242
                // Skip any bad records
243
                } catch (UnexpectedDataException $e) {
244
                }
245
            }
246
        }
247
248
        /** @var string[][] $explicit List of all items that are either in $explicit, $referenced or both */
249
        $all = array_merge($referenced, $explicit);
250
251
        /** @var string[][] $implicit Anything that is in $all, but not in $explicit, is an implicit inclusion */
252
        $implicit = array_diff_key($all, $explicit);
253
254
        foreach ($implicit as $key => $object) {
255
            $implicit[$key]['ReferencedBy'] = $references[$key];
256
        }
257
258
        return $implicit;
259
    }
260
261
    /**
262
     * Add implicit changes that should be included in this changeset
263
     *
264
     * When an item is created or changed, all it's owned items which have
265
     * changes are implicitly added
266
     *
267
     * When an item is deleted, it's owner (even if that owner does not have changes)
268
     * is implicitly added
269
     */
270
    public function sync()
271
    {
272
        // Start a transaction (if we can)
273
        DB::get_conn()->withTransaction(function () {
274
275
            // Get the implicitly included items for this ChangeSet
276
            $implicit = $this->calculateImplicit();
277
278
            // Adjust the existing implicit ChangeSetItems for this ChangeSet
279
            /** @var ChangeSetItem $item */
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, it's no longer required, so delete it
284
                if (!array_key_exists($objectKey, $implicit)) {
285
                    $item->delete();
286
                } // Otherwise it is required, so update ReferencedBy and remove from $implicit
287
                else {
288
                    $item->ReferencedBy()->setByIDList($implicit[$objectKey]['ReferencedBy']);
289
                    unset($implicit[$objectKey]);
290
                }
291
            }
292
293
            // Now $implicit is all those items that are implicitly included, but don't currently have a ChangeSetItem.
294
            // So create new ChangeSetItems to match
295
296
            foreach ($implicit as $key => $props) {
297
                $item = new ChangeSetItem($props);
298
                $item->Added = ChangeSetItem::IMPLICITLY;
299
                $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...
300
                $item->ReferencedBy()->setByIDList($props['ReferencedBy']);
301
                $item->write();
302
            }
303
        });
304
    }
305
306
    /** Verify that any objects in this changeset include all owned changes */
307
    public function isSynced()
308
    {
309
        $implicit = $this->calculateImplicit();
310
311
        // Check the existing implicit ChangeSetItems for this ChangeSet
312
313
        foreach ($this->Changes()->filter(['Added' => ChangeSetItem::IMPLICITLY]) as $item) {
314
            $objectKey = $this->implicitKey($item);
315
316
            // If a ChangeSetItem exists, but isn't in $implicit -> validation failure
317
            if (!array_key_exists($objectKey, $implicit)) {
318
                return false;
319
            }
320
            // Exists, remove from $implicit
321
            unset($implicit[$objectKey]);
322
        }
323
324
        // If there's anything left in $implicit -> validation failure
325
        return empty($implicit);
326
    }
327
328
    public function canView($member = null)
329
    {
330
        return $this->can(__FUNCTION__, $member);
331
    }
332
333
    public function canEdit($member = null)
334
    {
335
        return $this->can(__FUNCTION__, $member);
336
    }
337
338
    public function canCreate($member = null, $context = array())
339
    {
340
        return $this->can(__FUNCTION__, $member, $context);
341
    }
342
343
    public function canDelete($member = null)
344
    {
345
        return $this->can(__FUNCTION__, $member);
346
    }
347
348
    /**
349
     * Check if this item is allowed to be published
350
     *
351
     * @param Member $member
352
     * @return bool
353
     */
354
    public function canPublish($member = null)
355
    {
356
        // All changes must be publishable
357
        $atLeastOneChange = false;
358
        foreach ($this->Changes() as $change) {
359
            $atLeastOneChange = true;
360
            /** @var ChangeSetItem $change */
361
            if (!$change->canPublish($member)) {
362
                return false;
363
            }
364
        }
365
366
        return $atLeastOneChange;
367
    }
368
369
    /**
370
     * Check if this changeset (if published) can be reverted
371
     *
372
     * @param Member $member
373
     * @return bool
374
     */
375
    public function canRevert($member = null)
376
    {
377
        // All changes must be publishable
378
        foreach ($this->Changes() as $change) {
379
            /** @var ChangeSetItem $change */
380
            if (!$change->canRevert($member)) {
0 ignored issues
show
Bug introduced by
It seems like $member defined by parameter $member on line 375 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...
381
                return false;
382
            }
383
        }
384
385
        // Default permission
386
        return $this->can(__FUNCTION__, $member);
387
    }
388
389
    /**
390
     * Default permissions for this changeset
391
     *
392
     * @param string $perm
393
     * @param Member $member
394
     * @param array $context
395
     * @return bool
396
     */
397
    public function can($perm, $member = null, $context = array())
398
    {
399
        if (!$member) {
400
            $member = Member::currentUser();
401
        }
402
403
        // Allow extensions to bypass default permissions, but only if
404
        // each change can be individually published.
405
        $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...
406
        if ($extended !== null) {
407
            return $extended;
408
        }
409
410
        // Default permissions
411
        return (bool)Permission::checkMember($member, $this->config()->required_permission);
412
    }
413
414
    public function getCMSFields()
415
    {
416
        $fields = new FieldList(new TabSet('Root'));
417
        if ($this->IsInferred) {
418
            $fields->addFieldToTab('Root.Main', ReadonlyField::create('Name', $this->fieldLabel('Name')));
419
        } else {
420
            $fields->addFieldToTab('Root.Main', TextField::create('Name', $this->fieldLabel('Name')));
421
        }
422
        if ($this->isInDB()) {
423
            $fields->addFieldToTab('Root.Main', ReadonlyField::create('State', $this->fieldLabel('State')));
424
        }
425
426
        $this->extend('updateCMSFields', $fields);
427
        return $fields;
428
    }
429
430
    /**
431
     * Gets summary of items in changeset
432
     *
433
     * @return string
434
     */
435
    public function getDescription()
436
    {
437
        // Initialise list of items to count
438
        $counted = [];
439
        $countedOther = 0;
440
        foreach ($this->config()->important_classes as $type) {
441
            if (class_exists($type)) {
442
                $counted[$type] = 0;
443
            }
444
        }
445
446
        // Check each change item
447
        /** @var ChangeSetItem $change */
448
        foreach ($this->Changes() as $change) {
449
            $found = false;
450
            foreach ($counted as $class => $num) {
451
                if (is_a($change->ObjectClass, $class, true)) {
452
                    $counted[$class]++;
453
                    $found = true;
454
                    break;
455
                }
456
            }
457
            if (!$found) {
458
                $countedOther++;
459
            }
460
        }
461
462
        // Describe set based on this output
463
        $counted = array_filter($counted);
464
465
        // Empty state
466
        if (empty($counted) && empty($countedOther)) {
467
            return '';
468
        }
469
470
        // Put all parts together
471
        $parts = [];
472
        foreach ($counted as $class => $count) {
473
            $parts[] = DataObject::singleton($class)->i18n_pluralise($count);
474
        }
475
476
        // Describe non-important items
477
        if ($countedOther) {
478
            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...
479
                $parts[] = i18n::pluralise(
480
                    _t('ChangeSet.DESCRIPTION_OTHER_ITEM', 'other item'),
481
                    _t('ChangeSet.DESCRIPTION_OTHER_ITEMS', 'other items'),
482
                    $countedOther
483
                );
484
            } else {
485
                $parts[] = i18n::pluralise(
486
                    _t('ChangeSet.DESCRIPTION_ITEM', 'item'),
487
                    _t('ChangeSet.DESCRIPTION_ITEMS', 'items'),
488
                    $countedOther
489
                );
490
            }
491
        }
492
493
        // Figure out how to join everything together
494
        if (empty($parts)) {
495
            return '';
496
        }
497
        if (count($parts) === 1) {
498
            return $parts[0];
499
        }
500
501
        // Non-comma list
502
        if (count($parts) === 2) {
503
            return _t(
504
                'ChangeSet.DESCRIPTION_AND',
505
                '{first} and {second}',
506
                [
507
                    'first' => $parts[0],
508
                    'second' => $parts[1],
509
                ]
510
            );
511
        }
512
513
        // First item
514
        $string = _t(
515
            'ChangeSet.DESCRIPTION_LIST_FIRST',
516
            '{item}',
517
            ['item' => $parts[0]]
518
        );
519
520
        // Middle items
521
        for ($i = 1; $i < count($parts) - 1; $i++) {
522
            $string = _t(
523
                'ChangeSet.DESCRIPTION_LIST_MID',
524
                '{list}, {item}',
525
                [
526
                    'list' => $string,
527
                    'item' => $parts[$i]
528
                ]
529
            );
530
        }
531
532
        // Oxford comma
533
        $string = _t(
534
            'ChangeSet.DESCRIPTION_LIST_LAST',
535
            '{list}, and {item}',
536
            [
537
                'list' => $string,
538
                'item' => end($parts)
539
            ]
540
        );
541
        return $string;
542
    }
543
544
    /**
545
     * Required to support count display in react gridfield column
546
     *
547
     * @return int
548
     */
549
    public function getChangesCount()
550
    {
551
        return $this->Changes()->count();
552
    }
553
554
    public function fieldLabels($includerelations = true)
555
    {
556
        $labels = parent::fieldLabels($includerelations);
557
        $labels['Name'] = _t('ChangeSet.NAME', 'Name');
558
        $labels['State'] = _t('ChangeSet.STATE', 'State');
559
560
        return $labels;
561
    }
562
}
563