Passed
Push — master ( 9b9c6c...ef704e )
by Daniel
35:52 queued 24:20
created

src/ORM/Hierarchy/Hierarchy.php (8 issues)

1
<?php
2
3
namespace SilverStripe\ORM\Hierarchy;
4
5
use SilverStripe\Admin\LeftAndMain;
6
use SilverStripe\Control\Controller;
7
use SilverStripe\ORM\DataList;
8
use SilverStripe\ORM\SS_List;
9
use SilverStripe\ORM\ValidationResult;
10
use SilverStripe\ORM\ArrayList;
11
use SilverStripe\ORM\DataObject;
12
use SilverStripe\ORM\DataExtension;
13
use SilverStripe\Versioned\Versioned;
14
use Exception;
15
16
/**
17
 * DataObjects that use the Hierarchy extension can be be organised as a hierarchy, with children and parents. The most
18
 * obvious example of this is SiteTree.
19
 *
20
 * @property int $ParentID
21
 * @property DataObject|Hierarchy $owner
22
 * @method DataObject Parent()
23
 */
24
class Hierarchy extends DataExtension
25
{
26
    /**
27
     * The lower bounds for the amount of nodes to mark. If set, the logic will expand nodes until it reaches at least
28
     * this number, and then stops. Root nodes will always show regardless of this settting. Further nodes can be
29
     * lazy-loaded via ajax. This isn't a hard limit. Example: On a value of 10, with 20 root nodes, each having 30
30
     * children, the actual node count will be 50 (all root nodes plus first expanded child).
31
     *
32
     * @config
33
     * @var int
34
     */
35
    private static $node_threshold_total = 50;
36
37
    /**
38
     * Limit on the maximum children a specific node can display. Serves as a hard limit to avoid exceeding available
39
     * server resources in generating the tree, and browser resources in rendering it. Nodes with children exceeding
40
     * this value typically won't display any children, although this is configurable through the $nodeCountCallback
41
     * parameter in {@link getChildrenAsUL()}. "Root" nodes will always show all children, regardless of this setting.
42
     *
43
     * @config
44
     * @var int
45
     */
46
    private static $node_threshold_leaf = 250;
47
48
    /**
49
     * A list of classnames to exclude from display in both the CMS and front end
50
     * displays. ->Children() and ->AllChildren affected.
51
     * Especially useful for big sets of pages like listings
52
     * If you use this, and still need the classes to be editable
53
     * then add a model admin for the class
54
     * Note: Does not filter subclasses (non-inheriting)
55
     *
56
     * @var array
57
     * @config
58
     */
59
    private static $hide_from_hierarchy = array();
60
61
    /**
62
     * A list of classnames to exclude from display in the page tree views of the CMS,
63
     * unlike $hide_from_hierarchy above which effects both CMS and front end.
64
     * Especially useful for big sets of pages like listings
65
     * If you use this, and still need the classes to be editable
66
     * then add a model admin for the class
67
     * Note: Does not filter subclasses (non-inheriting)
68
     *
69
     * @var array
70
     * @config
71
     */
72
    private static $hide_from_cms_tree = array();
73
74
    /**
75
     * Prevent virtual page virtualising these fields
76
     *
77
     * @config
78
     * @var array
79
     */
80
    private static $non_virtual_fields = [
81
        '_cache_children',
82
        '_cache_numChildren',
83
    ];
84
85
    public static function get_extra_config($class, $extension, $args)
86
    {
87
        return array(
88
            'has_one' => array('Parent' => $class)
89
        );
90
    }
91
92
    /**
93
     * Validate the owner object - check for existence of infinite loops.
94
     *
95
     * @param ValidationResult $validationResult
96
     */
97
    public function validate(ValidationResult $validationResult)
98
    {
99
        // The object is new, won't be looping.
100
        /** @var DataObject|Hierarchy $owner */
101
        $owner = $this->owner;
102
        if (!$owner->ID) {
103
            return;
104
        }
105
        // The object has no parent, won't be looping.
106
        if (!$owner->ParentID) {
107
            return;
108
        }
109
        // The parent has not changed, skip the check for performance reasons.
110
        if (!$owner->isChanged('ParentID')) {
111
            return;
112
        }
113
114
        // Walk the hierarchy upwards until we reach the top, or until we reach the originating node again.
115
        $node = $owner;
116
        while ($node && $node->ParentID) {
117
            if ((int)$node->ParentID === (int)$owner->ID) {
118
                // Hierarchy is looping.
119
                $validationResult->addError(
120
                    _t(
121
                        __CLASS__ . '.InfiniteLoopNotAllowed',
122
                        'Infinite loop found within the "{type}" hierarchy. Please change the parent to resolve this',
123
                        'First argument is the class that makes up the hierarchy.',
124
                        array('type' => get_class($owner))
125
                    ),
126
                    'bad',
127
                    'INFINITE_LOOP'
128
                );
129
                break;
130
            }
131
            $node = $node->Parent();
132
        }
133
    }
134
135
136
    /**
137
     * Get a list of this DataObject's and all it's descendants IDs.
138
     *
139
     * @return int[]
140
     */
141
    public function getDescendantIDList()
142
    {
143
        $idList = array();
144
        $this->loadDescendantIDListInto($idList);
145
        return $idList;
146
    }
147
148
    /**
149
     * Get a list of this DataObject's and all it's descendants ID, and put them in $idList.
150
     *
151
     * @param array $idList Array to put results in.
152
     * @param DataObject|Hierarchy $node
153
     */
154
    protected function loadDescendantIDListInto(&$idList, $node = null)
155
    {
156
        if (!$node) {
157
            $node = $this->owner;
158
        }
159
        $children = $node->AllChildren();
160
        foreach ($children as $child) {
161
            if (!in_array($child->ID, $idList)) {
162
                $idList[] = $child->ID;
163
                $this->loadDescendantIDListInto($idList, $child);
164
            }
165
        }
166
    }
167
168
    /**
169
     * Get the children for this DataObject filtered by canView()
170
     *
171
     * @return SS_List
172
     */
173
    public function Children()
174
    {
175
        $children = $this->owner->_cache_children;
176
        if ($children) {
177
            return $children;
178
        }
179
180
        $children = $this
181
            ->owner
182
            ->stageChildren(false)
183
            ->filterByCallback(function (DataObject $record) {
184
                return $record->canView();
185
            });
186
        $this->owner->_cache_children = $children;
187
        return $children;
188
    }
189
190
    /**
191
     * Return all children, including those 'not in menus'.
192
     *
193
     * @return DataList
194
     */
195
    public function AllChildren()
196
    {
197
        return $this->owner->stageChildren(true);
198
    }
199
200
    /**
201
     * Return all children, including those that have been deleted but are still in live.
202
     * - Deleted children will be marked as "DeletedFromStage"
203
     * - Added children will be marked as "AddedToStage"
204
     * - Modified children will be marked as "ModifiedOnStage"
205
     * - Everything else has "SameOnStage" set, as an indicator that this information has been looked up.
206
     *
207
     * @return ArrayList
208
     */
209
    public function AllChildrenIncludingDeleted()
210
    {
211
        $stageChildren = $this->owner->stageChildren(true);
212
213
        // Add live site content that doesn't exist on the stage site, if required.
214
        if ($this->owner->hasExtension(Versioned::class)) {
0 ignored issues
show
The method hasExtension() does not exist on SilverStripe\ORM\Hierarchy\Hierarchy. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

214
        if ($this->owner->/** @scrutinizer ignore-call */ hasExtension(Versioned::class)) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
215
            // Next, go through the live children.  Only some of these will be listed
216
            $liveChildren = $this->owner->liveChildren(true, true);
217
            if ($liveChildren) {
218
                $merged = new ArrayList();
219
                $merged->merge($stageChildren);
220
                $merged->merge($liveChildren);
221
                $stageChildren = $merged;
222
            }
223
        }
224
        $this->owner->extend("augmentAllChildrenIncludingDeleted", $stageChildren);
225
        return $stageChildren;
226
    }
227
228
    /**
229
     * Return all the children that this page had, including pages that were deleted from both stage & live.
230
     *
231
     * @return DataList
232
     * @throws Exception
233
     */
234
    public function AllHistoricalChildren()
235
    {
236
        if (!$this->owner->hasExtension(Versioned::class)) {
237
            throw new Exception('Hierarchy->AllHistoricalChildren() only works with Versioned extension applied');
238
        }
239
240
        $baseTable = $this->owner->baseTable();
0 ignored issues
show
The method baseTable() does not exist on SilverStripe\ORM\Hierarchy\Hierarchy. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

240
        /** @scrutinizer ignore-call */ 
241
        $baseTable = $this->owner->baseTable();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
241
        $parentIDColumn = $this->owner->getSchema()->sqlColumnForField($this->owner, 'ParentID');
0 ignored issues
show
It seems like $this->owner can also be of type SilverStripe\ORM\Hierarchy\Hierarchy; however, parameter $class of SilverStripe\ORM\DataObj...ma::sqlColumnForField() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

241
        $parentIDColumn = $this->owner->getSchema()->sqlColumnForField(/** @scrutinizer ignore-type */ $this->owner, 'ParentID');
Loading history...
The method getSchema() does not exist on SilverStripe\ORM\Hierarchy\Hierarchy. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

241
        $parentIDColumn = $this->owner->/** @scrutinizer ignore-call */ getSchema()->sqlColumnForField($this->owner, 'ParentID');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
242
        return Versioned::get_including_deleted(
243
            $this->owner->baseClass(),
244
            [ $parentIDColumn => $this->owner->ID ],
0 ignored issues
show
Bug Best Practice introduced by
The property ID does not exist on SilverStripe\ORM\Hierarchy\Hierarchy. Did you maybe forget to declare it?
Loading history...
array($parentIDColumn => $this->owner->ID) of type array<string,integer> is incompatible with the type string expected by parameter $filter of SilverStripe\Versioned\V...get_including_deleted(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

244
            /** @scrutinizer ignore-type */ [ $parentIDColumn => $this->owner->ID ],
Loading history...
245
            "\"{$baseTable}\".\"ID\" ASC"
246
        );
247
    }
248
249
    /**
250
     * Return the number of children that this page ever had, including pages that were deleted.
251
     *
252
     * @return int
253
     */
254
    public function numHistoricalChildren()
255
    {
256
        return $this->AllHistoricalChildren()->count();
257
    }
258
259
    /**
260
     * Return the number of direct children. By default, values are cached after the first invocation. Can be
261
     * augumented by {@link augmentNumChildrenCountQuery()}.
262
     *
263
     * @param bool $cache Whether to retrieve values from cache
264
     * @return int
265
     */
266
    public function numChildren($cache = true)
267
    {
268
        // Load if caching
269
        if ($cache) {
270
            $numChildren = $this->owner->_cache_numChildren;
271
            if (isset($numChildren)) {
272
                return $numChildren;
273
            }
274
        }
275
276
        // We call stageChildren(), because Children() has canView() filtering
277
        $numChildren = (int)$this->owner->stageChildren(true)->Count();
278
279
        // Save if caching
280
        if ($cache) {
281
            $this->owner->_cache_numChildren = $numChildren;
282
        }
283
        return $numChildren;
284
    }
285
286
    /**
287
     * Checks if we're on a controller where we should filter. ie. Are we loading the SiteTree?
288
     *
289
     * @return bool
290
     */
291
    public function showingCMSTree()
292
    {
293
        if (!Controller::has_curr() || !class_exists(LeftAndMain::class)) {
294
            return false;
295
        }
296
        $controller = Controller::curr();
297
        return $controller instanceof LeftAndMain
298
            && in_array($controller->getAction(), array("treeview", "listview", "getsubtree"));
299
    }
300
301
    /**
302
     * Return children in the stage site.
303
     *
304
     * @param bool $showAll Include all of the elements, even those not shown in the menus. Only applicable when
305
     *                      extension is applied to {@link SiteTree}.
306
     * @return DataList
307
     */
308
    public function stageChildren($showAll = false)
309
    {
310
        $hideFromHierarchy = $this->owner->config()->hide_from_hierarchy;
311
        $hideFromCMSTree = $this->owner->config()->hide_from_cms_tree;
312
        $baseClass = $this->owner->baseClass();
313
        $staged = DataObject::get($baseClass)
314
                ->filter('ParentID', (int)$this->owner->ID)
315
                ->exclude('ID', (int)$this->owner->ID);
316
        if ($hideFromHierarchy) {
317
            $staged = $staged->exclude('ClassName', $hideFromHierarchy);
318
        }
319
        if ($hideFromCMSTree && $this->showingCMSTree()) {
320
            $staged = $staged->exclude('ClassName', $hideFromCMSTree);
321
        }
322
        if (!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) {
323
            $staged = $staged->filter('ShowInMenus', 1);
324
        }
325
        $this->owner->extend("augmentStageChildren", $staged, $showAll);
326
        return $staged;
327
    }
328
329
    /**
330
     * Return children in the live site, if it exists.
331
     *
332
     * @param bool $showAll              Include all of the elements, even those not shown in the menus. Only
333
     *                                   applicable when extension is applied to {@link SiteTree}.
334
     * @param bool $onlyDeletedFromStage Only return items that have been deleted from stage
335
     * @return DataList
336
     * @throws Exception
337
     */
338
    public function liveChildren($showAll = false, $onlyDeletedFromStage = false)
339
    {
340
        if (!$this->owner->hasExtension(Versioned::class)) {
341
            throw new Exception('Hierarchy->liveChildren() only works with Versioned extension applied');
342
        }
343
344
        $hideFromHierarchy = $this->owner->config()->hide_from_hierarchy;
345
        $hideFromCMSTree = $this->owner->config()->hide_from_cms_tree;
346
        $children = DataObject::get($this->owner->baseClass())
347
            ->filter('ParentID', (int)$this->owner->ID)
0 ignored issues
show
Bug Best Practice introduced by
The property ID does not exist on SilverStripe\ORM\Hierarchy\Hierarchy. Did you maybe forget to declare it?
Loading history...
348
            ->exclude('ID', (int)$this->owner->ID)
349
            ->setDataQueryParam(array(
350
                'Versioned.mode' => $onlyDeletedFromStage ? 'stage_unique' : 'stage',
351
                'Versioned.stage' => 'Live'
352
            ));
353
        if ($hideFromHierarchy) {
354
            $children = $children->exclude('ClassName', $hideFromHierarchy);
355
        }
356
        if ($hideFromCMSTree && $this->showingCMSTree()) {
357
            $children = $children->exclude('ClassName', $hideFromCMSTree);
358
        }
359
        if (!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) {
0 ignored issues
show
It seems like $this->owner can also be of type SilverStripe\ORM\Hierarchy\Hierarchy; however, parameter $classOrInstance of SilverStripe\ORM\DataObjectSchema::fieldSpec() does only seem to accept SilverStripe\ORM\DataObject|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

359
        if (!$showAll && DataObject::getSchema()->fieldSpec(/** @scrutinizer ignore-type */ $this->owner, 'ShowInMenus')) {
Loading history...
360
            $children = $children->filter('ShowInMenus', 1);
361
        }
362
363
        return $children;
364
    }
365
366
    /**
367
     * Get this object's parent, optionally filtered by an SQL clause. If the clause doesn't match the parent, nothing
368
     * is returned.
369
     *
370
     * @param string $filter
371
     * @return DataObject
372
     */
373
    public function getParent($filter = null)
374
    {
375
        $parentID = $this->owner->ParentID;
376
        if (empty($parentID)) {
377
            return null;
378
        }
379
        $baseClass = $this->owner->baseClass();
380
        $idSQL = $this->owner->getSchema()->sqlColumnForField($baseClass, 'ID');
381
        return DataObject::get_one($baseClass, [
382
            [$idSQL => $parentID],
383
            $filter
384
        ]);
385
    }
386
387
    /**
388
     * Return all the parents of this class in a set ordered from the closest to furtherest parent.
389
     *
390
     * @param bool $includeSelf
391
     * @return ArrayList
392
     */
393
    public function getAncestors($includeSelf = false)
394
    {
395
        $ancestors = new ArrayList();
396
        $object = $this->owner;
397
398
        if ($includeSelf) {
399
            $ancestors->push($object);
400
        }
401
        while ($object = $object->getParent()) {
402
            $ancestors->push($object);
403
        }
404
405
        return $ancestors;
406
    }
407
408
    /**
409
     * Returns a human-readable, flattened representation of the path to the object, using its {@link Title} attribute.
410
     *
411
     * @param string $separator
412
     * @return string
413
     */
414
    public function getBreadcrumbs($separator = ' &raquo; ')
415
    {
416
        $crumbs = array();
417
        $ancestors = array_reverse($this->owner->getAncestors()->toArray());
418
        /** @var DataObject $ancestor */
419
        foreach ($ancestors as $ancestor) {
420
            $crumbs[] = $ancestor->getTitle();
421
        }
422
        $crumbs[] = $this->owner->getTitle();
423
        return implode($separator, $crumbs);
424
    }
425
426
    /**
427
     * Flush all Hierarchy caches:
428
     * - Children (instance)
429
     * - NumChildren (instance)
430
     */
431
    public function flushCache()
432
    {
433
        $this->owner->_cache_children = null;
434
        $this->owner->_cache_numChildren = null;
435
    }
436
}
437