Passed
Push — 4 ( eae0ff...3ed7c8 )
by Steve
64:51 queued 12s
created

DataExtension::getFixedTopPageID()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace DNADesign\Elemental\TopPage;
4
5
use DNADesign\Elemental\Models\BaseElement;
6
use DNADesign\Elemental\Models\ElementalArea;
7
use Page;
0 ignored issues
show
Bug introduced by
The type Page was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
8
use SilverStripe\Core\ClassInfo;
9
use SilverStripe\Core\Extensible;
10
use SilverStripe\ORM\DataExtension as BaseDataExtension;
11
use SilverStripe\ORM\DataObject;
12
use SilverStripe\ORM\Queries\SQLUpdate;
13
use SilverStripe\ORM\ValidationException;
14
use SilverStripe\Versioned\Versioned;
15
16
/**
17
 * Class DataExtension
18
 *
19
 * Provides a db-cached reference to the top-level page for improved read performance on projects
20
 * with deeply nested block structures. Apply to @see BaseElement and @see ElementalArea.
21
 *
22
 * @property int $TopPageID
23
 * @method Page TopPage()
24
 * @property BaseElement|ElementalArea|$this $owner
25
 * @package DNADesign\Elemental\TopPage
26
 */
27
class DataExtension extends BaseDataExtension
28
{
29
    /**
30
     * @config
31
     * @var array
32
     */
33
    private static $has_one = [
0 ignored issues
show
introduced by
The private property $has_one is not used, and could be removed.
Loading history...
34
        'TopPage' => Page::class,
35
    ];
36
37
    /**
38
     * @config
39
     * @var array
40
     */
41
    private static $indexes = [
0 ignored issues
show
introduced by
The private property $indexes is not used, and could be removed.
Loading history...
42
        'TopPageID' => true,
43
    ];
44
45
    /**
46
     * Global flag which indicates if this feature is enabled or not
47
     *
48
     * @see DataExtension::withTopPageUpdate()
49
     * @var bool
50
     */
51
    private $topPageUpdate = true;
52
53
    /**
54
     * Global flag which indicates that automatic page determination is enabled or not
55
     * If this is set to a page ID it will be used instead of trying to determine the top page
56
     *
57
     * @see DataExtension::withFixedTopPage()
58
     * @var int
59
     */
60
    private $fixedTopPageID = 0;
61
62
    /**
63
     * Extension point in @see DataObject::onAfterWrite()
64
     *
65
     * @throws ValidationException
66
     */
67
    public function onAfterWrite(): void
68
    {
69
        $this->setTopPage();
70
    }
71
72
    /**
73
     * Extension point in @see DataObject::duplicate()
74
     */
75
    public function onBeforeDuplicate(): void
76
    {
77
        $this->clearTopPage();
78
    }
79
80
    /**
81
     * Extension point in @see DataObject::duplicate()
82
     */
83
    public function onAfterDuplicate(): void
84
    {
85
        $this->updateTopPage();
86
    }
87
88
    /**
89
     * Finds the top-level Page object for a Block / ElementalArea, using the cached TopPageID
90
     * reference when possible.
91
     *
92
     * @return Page|null
93
     * @throws ValidationException
94
     */
95
    public function getTopPage(): ?Page
96
    {
97
        $list = [$this->owner];
98
99
        while (count($list) > 0) {
100
            /** @var DataObject|DataExtension $item */
101
            $item = array_shift($list);
102
103
            if (!$item->exists()) {
0 ignored issues
show
Bug introduced by
The method exists() does not exist on DNADesign\Elemental\TopPage\DataExtension. ( Ignorable by Annotation )

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

103
            if (!$item->/** @scrutinizer ignore-call */ exists()) {

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...
104
                continue;
105
            }
106
107
            if ($item instanceof Page) {
108
                // trivial case
109
                return $item;
110
            }
111
112
            if ($item->hasExtension(DataExtension::class) && $item->TopPageID > 0) {
0 ignored issues
show
Bug introduced by
The method hasExtension() does not exist on DNADesign\Elemental\TopPage\DataExtension. ( Ignorable by Annotation )

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

112
            if ($item->/** @scrutinizer ignore-call */ hasExtension(DataExtension::class) && $item->TopPageID > 0) {

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...
113
                // top page is stored inside data object - just fetch it via cached call
114
                $page = $this->getTopPageFromCachedData((int) $item->TopPageID);
115
116
                if ($page) {
117
                    return $page;
118
                }
119
            }
120
121
            if ($item instanceof BaseElement) {
122
                // parent lookup via block
123
                $parent = $item->Parent();
124
125
                if ($parent !== null) {
126
                    array_push($list, $parent);
127
                }
128
129
                continue;
130
            }
131
132
            if ($item instanceof ElementalArea) {
133
                // parent lookup via elemental area
134
                $parent = $item->getOwnerPage();
135
136
                if ($parent !== null) {
137
                    array_push($list, $parent);
138
                }
139
140
                continue;
141
            }
142
        }
143
144
        return null;
145
    }
146
147
    /**
148
     * Set top page to an object
149
     * If no page is provided as an argument nor as a fixed id via @see DataExtension::withFixedTopPage()
150
     * automatic page determination will be attempted
151
     * Note that this may not always succeed as your model may not be attached to parent object at the time of this call
152
     *
153
     * @param Page|null $page
154
     * @throws ValidationException
155
     */
156
    public function setTopPage(?Page $page = null): void
157
    {
158
        if (!$this->getTopPageUpdate()) {
159
            return;
160
        }
161
162
        /** @var BaseElement|ElementalArea|Versioned|DataExtension $owner */
163
        $owner = $this->owner;
164
165
        if (!$owner->hasExtension(DataExtension::class)) {
166
            return;
167
        }
168
169
        if ($owner->TopPageID > 0) {
0 ignored issues
show
Bug Best Practice introduced by
The property TopPageID does not exist on DNADesign\Elemental\Models\ElementalArea. Since you implemented __get, consider adding a @property annotation.
Loading history...
Bug Best Practice introduced by
The property TopPageID does not exist on DNADesign\Elemental\Models\BaseElement. Since you implemented __get, consider adding a @property annotation.
Loading history...
170
            return;
171
        }
172
173
        if ($this->getFixedTopPageID() > 0) {
174
            $this->assignFixedTopPage();
175
            $this->saveChanges();
176
177
            return;
178
        }
179
180
        $page = $page ?? $owner->getTopPage();
0 ignored issues
show
Bug introduced by
The method getTopPage() does not exist on DNADesign\Elemental\Models\ElementalArea. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

180
        $page = $page ?? $owner->/** @scrutinizer ignore-call */ getTopPage();
Loading history...
Bug introduced by
The method getTopPage() does not exist on DNADesign\Elemental\Models\BaseElement. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

180
        $page = $page ?? $owner->/** @scrutinizer ignore-call */ getTopPage();
Loading history...
181
182
        if ($page === null) {
183
            return;
184
        }
185
186
        // set the page to properties in case this object is re-used later
187
        $this->assignTopPage($page);
188
        $this->saveChanges();
189
    }
190
191
    public function getTopPageUpdate(): bool
192
    {
193
        return $this->topPageUpdate;
194
    }
195
196
    /**
197
     * Global flag manipulation - enable automatic top page determination
198
     * Useful for unit tests as you may want to enable / disable this feature based on need
199
     */
200
    public function enableTopPageUpdate(): void
201
    {
202
        $this->topPageUpdate = true;
203
    }
204
205
    /**
206
     * Global flag manipulation - disable automatic top page determination
207
     * Useful for unit tests as you may want to enable / disable this feature based on need
208
     */
209
    public function disableTopPageUpdate(): void
210
    {
211
        $this->topPageUpdate = false;
212
    }
213
214
    /**
215
     * Use this to wrap any code which is supposed to run with desired top page update setting
216
     * Useful for unit tests as you may want to enable / disable this feature based on need
217
     *
218
     * @param bool $update
219
     * @param callable $callback
220
     * @return mixed
221
     */
222
    public function withTopPageUpdate(bool $update, callable $callback)
223
    {
224
        $original = $this->topPageUpdate;
225
        $this->topPageUpdate = $update;
226
227
        try {
228
            return $callback();
229
        } finally {
230
            $this->topPageUpdate = $original;
231
        }
232
    }
233
234
    /**
235
     * Use this to wrap any code which is supposed to run with fixed top page
236
     * Useful when top page is known upfront and doesn't need to be determined
237
     * For example: model duplication where parent is assigned and saved only after the duplication is done
238
     * It's not possible to determine top page in such case however it might be possible to know the top page
239
     * even before the operation starts from the specific context
240
     * Setting the page id to 0 disables this feature
241
     *
242
     * @param int $topPageID
243
     * @param callable $callback
244
     * @return mixed
245
     */
246
    public function withFixedTopPage(int $topPageID, callable $callback)
247
    {
248
        $original = $this->fixedTopPageID;
249
        $this->fixedTopPageID = $topPageID;
250
251
        try {
252
            return $callback();
253
        } finally {
254
            $this->fixedTopPageID = $original;
255
        }
256
    }
257
258
    /**
259
     * Get the ID of a page which is currently set as the fixed top page
260
     *
261
     * @return int
262
     */
263
    protected function getFixedTopPageID(): int
264
    {
265
        return $this->fixedTopPageID;
266
    }
267
268
    /**
269
     * Registers the object for a TopPage update. Ensures that this operation is deferred to a point
270
     * when all required relations have been written.
271
     */
272
    protected function updateTopPage(): void
273
    {
274
        if (!$this->getTopPageUpdate()) {
275
            return;
276
        }
277
278
        /** @var SiteTreeExtension $extension */
279
        $extension = singleton(SiteTreeExtension::class);
280
        $extension->addDuplicatedObject($this->owner);
0 ignored issues
show
Bug introduced by
It seems like $this->owner can also be of type DNADesign\Elemental\TopPage\DataExtension; however, parameter $object of DNADesign\Elemental\TopP...::addDuplicatedObject() does only seem to accept SilverStripe\ORM\DataObject, 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

280
        $extension->addDuplicatedObject(/** @scrutinizer ignore-type */ $this->owner);
Loading history...
281
    }
282
283
    /**
284
     * Assigns top page relation
285
     *
286
     * @param Page $page
287
     */
288
    protected function assignTopPage(Page $page): void
289
    {
290
        $this->owner->TopPageID = (int) $page->ID;
0 ignored issues
show
Bug Best Practice introduced by
The property TopPageID does not exist on DNADesign\Elemental\Models\BaseElement. Since you implemented __set, consider adding a @property annotation.
Loading history...
Bug Best Practice introduced by
The property TopPageID does not exist on DNADesign\Elemental\Models\ElementalArea. Since you implemented __set, consider adding a @property annotation.
Loading history...
291
    }
292
293
    /**
294
     * Clears top page relation, this is useful when duplicating object as the new object doesn't necessarily
295
     * belong to the original page
296
     */
297
    protected function clearTopPage(): void
298
    {
299
        $this->owner->TopPageID = 0;
0 ignored issues
show
Bug Best Practice introduced by
The property TopPageID does not exist on DNADesign\Elemental\Models\ElementalArea. Since you implemented __set, consider adding a @property annotation.
Loading history...
Bug Best Practice introduced by
The property TopPageID does not exist on DNADesign\Elemental\Models\BaseElement. Since you implemented __set, consider adding a @property annotation.
Loading history...
300
    }
301
302
    /**
303
     * Assigns top page relation based on fixed id
304
     *
305
     * @see DataExtension::withFixedTopPage()
306
     */
307
    protected function assignFixedTopPage(): void
308
    {
309
        $this->owner->TopPageID = $this->getFixedTopPageID();
0 ignored issues
show
Bug Best Practice introduced by
The property TopPageID does not exist on DNADesign\Elemental\Models\BaseElement. Since you implemented __set, consider adding a @property annotation.
Loading history...
Bug Best Practice introduced by
The property TopPageID does not exist on DNADesign\Elemental\Models\ElementalArea. Since you implemented __set, consider adding a @property annotation.
Loading history...
310
    }
311
312
    /**
313
     * Save top page changes without using write()
314
     * Using raw query here because:
315
     * - this is already called during write() and triggering more write() related extension points is undesirable
316
     * - we don't want to create a new version if object is versioned
317
     * - using writeWithoutVersion() produces some weird edge cases were data is not written
318
     * because the fields are not recognised as changed (using forceChange() introduces a new set of issues)
319
     *
320
     * @param array $extraData
321
     */
322
    protected function saveChanges(array $extraData = []): void
323
    {
324
        /** @var DataObject|DataExtension $owner */
325
        $owner = $this->owner;
326
        $table = $this->getTopPageTable();
327
328
        if (!$table) {
329
            return;
330
        }
331
332
        $updates = array_merge(
333
            [
334
                '"TopPageID"' => $owner->TopPageID,
335
            ],
336
            $extraData
337
        );
338
339
        $query = SQLUpdate::create(
340
            sprintf('"%s"', $table),
341
            $updates,
342
            ['"ID"' => $owner->ID]
0 ignored issues
show
Bug Best Practice introduced by
The property ID does not exist on DNADesign\Elemental\TopPage\DataExtension. Did you maybe forget to declare it?
Loading history...
343
        );
344
345
        $query->execute();
346
    }
347
348
    /**
349
     * Perform a page lookup based on cached data
350
     * This function allows more extensibility as it can be fully overridden unlike an extension point
351
     * Various projects may decide to alter this by injecting features like tracking, feature flags
352
     * and even completely different data lookups
353
     * This is a performance driven functionality so extension points are not great as they only allow adding
354
     * features on top of existing ones not replacing them
355
     *
356
     * @param int $id
357
     * @return Page|null
358
     */
359
    protected function getTopPageFromCachedData(int $id): ?Page
360
    {
361
        $page = Page::get_by_id($id);
362
363
        if (!$page || !$page->exists()) {
364
            return null;
365
        }
366
367
        return $page;
368
    }
369
370
    /**
371
     * Find table name which has the top page fields
372
     *
373
     * @return string
374
     */
375
    protected function getTopPageTable(): string
376
    {
377
        // Classes are ordered from generic to specific, top-down, left-right
378
        $classes = ClassInfo::dataClassesFor($this->owner);
379
380
        // Find the first ancestor table which has the extension applied
381
        // Note that this extension is expected to be subclassed
382
        foreach ($classes as $class) {
383
            if (!Extensible::has_extension($class, static::class)) {
384
                continue;
385
            }
386
387
            return DataObject::getSchema()->tableName($class);
388
        }
389
390
        return '';
391
    }
392
}
393