RegistryPageController   F
last analyzed

Complexity

Total Complexity 75

Size/Duplication

Total Lines 489
Duplicated Lines 0 %

Importance

Changes 13
Bugs 0 Features 0
Metric Value
wmc 75
eloc 199
c 13
b 0
f 0
dl 0
loc 489
rs 2.4

18 Methods

Rating   Name   Duplication   Size   Complexity  
B canSortBy() 0 21 7
A escapeSelect() 0 7 1
A doRegistryFilter() 0 16 3
A doRegistryFilterReset() 0 4 1
A Sort() 0 3 2
A export() 0 49 5
A AllQueryVars() 0 3 1
A queryVars() 0 24 4
A OppositeDirection() 0 18 4
B queryList() 0 41 8
A getClassNameForUrl() 0 3 1
B RegistryFilterForm() 0 34 7
A QueryLink() 0 7 1
A instanceHasRelationship() 0 9 3
A show() 0 15 4
C getTemplateList() 0 47 12
A RegistryEntries() 0 11 2
B Columns() 0 50 9

How to fix   Complexity   

Complex Class

Complex classes like RegistryPageController 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.

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 RegistryPageController, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\Registry;
4
5
use PageController;
0 ignored issues
show
Bug introduced by
The type PageController 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...
6
use SilverStripe\Control\Controller;
7
use SilverStripe\Control\HTTPRequest;
8
use SilverStripe\Core\Convert;
9
use SilverStripe\Forms\FieldList;
10
use SilverStripe\Forms\Form;
11
use SilverStripe\Forms\FormAction;
12
use SilverStripe\Forms\HiddenField;
13
use SilverStripe\ORM\ArrayList;
14
use SilverStripe\ORM\DataObject;
15
use SilverStripe\ORM\PaginatedList;
16
use SilverStripe\ORM\SS_List;
17
use SilverStripe\Registry\Exception\RegistryException;
18
use SilverStripe\View\ArrayData;
19
use SilverStripe\View\ViewableData;
20
21
class RegistryPageController extends PageController
22
{
23
    private static $allowed_actions = [
24
        'RegistryFilterForm',
25
        'show',
26
        'export',
27
    ];
28
29
    /**
30
     * Whether to output headers when sending the export file. This can be disabled for example in unit tests.
31
     *
32
     * @config
33
     * @var bool
34
     */
35
    private static $output_headers = true;
36
37
    /**
38
     * Get all search query vars, compiled into a query string for a URL.
39
     * This will escape all the variables to avoid XSS.
40
     *
41
     * @return string
42
     */
43
    public function AllQueryVars()
44
    {
45
        return Convert::raw2xml(http_build_query($this->queryVars()));
46
    }
47
48
    /**
49
     * Get all search query vars except Sort and Dir, compiled into a query link.
50
     * This will escape all the variables to avoid XSS.
51
     *
52
     * @return string
53
     */
54
    public function QueryLink()
55
    {
56
        $vars = $this->queryVars();
57
        unset($vars['Sort']);
58
        unset($vars['Dir']);
59
60
        return Convert::raw2xml($this->Link('RegistryFilterForm') . '?' . http_build_query($vars));
61
    }
62
63
    public function Sort()
64
    {
65
        return isset($_GET['Sort']) ? $_GET['Sort'] : '';
66
    }
67
68
    /**
69
     * Return the opposite direction from the currently sorted column's direction.
70
     * @return string
71
     */
72
    public function OppositeDirection()
73
    {
74
        // If direction is set, then just reverse it.
75
        $direction = $this->request->getVar('Dir');
76
        if ($direction) {
77
            if ($direction === 'ASC') {
78
                return 'DESC';
79
            }
80
            return 'ASC';
81
        }
82
83
        // If the sort column is set, then we're sorting by ASC (default is omitted)
84
        if ($this->request->getVar('Sort')) {
85
            return 'DESC';
86
        }
87
88
        // Otherwise we're not sorting at all so default to ASC.
89
        return 'ASC';
90
    }
91
92
    public function RegistryFilterForm()
93
    {
94
        $singleton = $this->dataRecord->getDataSingleton();
95
        if (!$singleton) {
96
            return;
97
        }
98
99
        /** @var FieldList $fields */
100
        $fields = $singleton->getSearchFields();
101
102
        // Add the sort information.
103
        $vars = $this->getRequest()->getVars();
104
        $fields->merge(FieldList::create(
105
            HiddenField::create('Sort', 'Sort', (!$vars || empty($vars['Sort'])) ? 'ID' : $vars['Sort']),
106
            HiddenField::create('Dir', 'Dir', (!$vars || empty($vars['Dir'])) ? 'ASC' : $vars['Dir'])
107
        ));
108
109
        $actions = FieldList::create(
110
            FormAction::create('doRegistryFilter')->setTitle('Filter')->addExtraClass('btn btn-primary primary'),
111
            FormAction::create('doRegistryFilterReset')->setTitle('Clear')->addExtraClass('btn')
112
        );
113
114
        // Align vars to fields
115
        $values = [];
116
        foreach ($this->getRequest()->getVars() as $field => $value) {
117
            $values[str_replace('_', '.', $field)] = $value;
118
        }
119
120
        $form = Form::create($this, 'RegistryFilterForm', $fields, $actions);
121
        $form->loadDataFrom($values);
122
        $form->disableSecurityToken();
123
        $form->setFormMethod('get');
124
125
        return $form;
126
    }
127
128
    /**
129
     * Build up search filters from user's search criteria and hand off
130
     * to the {@link query()} method to search against the database.
131
     *
132
     * @param array $data Form request data
133
     * @param Form Form object for submitted form
134
     * @param HTTPRequest
135
     * @return array
136
     */
137
    public function doRegistryFilter($data, $form, $request)
138
    {
139
        $singleton = $this->dataRecord->getDataSingleton();
140
141
        // Restrict fields
142
        $fields = array_merge(['start', 'Sort', 'Dir'], $singleton->config()->get('searchable_fields'));
143
        $params = [];
144
        foreach ($fields as $field) {
145
            $value = $this->getRequest()->getVar(str_replace('.', '_', $field));
146
            if ($value) {
147
                $params[$field] = $value;
148
            }
149
        }
150
151
        // Link back to this page with the relevant parameters
152
        $this->redirect($this->Link('?' . http_build_query($params)));
153
    }
154
155
    public function doRegistryFilterReset($data, $form, $request)
156
    {
157
        // Link back to this page with no relevant parameters.
158
        $this->redirect($this->AbsoluteLink());
159
    }
160
161
    public function RegistryEntries($paginated = true)
162
    {
163
164
        $list = $this->queryList();
165
166
        if ($paginated) {
167
            $list = PaginatedList::create($list, $this->getRequest());
168
            $list->setPageLength($this->getPageLength());
169
        }
170
171
        return $list;
172
    }
173
174
    /**
175
     * Loosely check if the record can be sorted by a property
176
     * @param  string $property
177
     * @return boolean
178
     */
179
    public function canSortBy($property)
180
    {
181
        $canSort = false;
182
        $singleton = $this->dataRecord->getDataSingleton();
183
184
        if ($singleton) {
185
            $properties = explode('.', $property);
186
187
            $relationClass = $singleton->getRelationClass($properties[0]);
188
            if ($relationClass) {
189
                if (count($properties) <= 2 && singleton($relationClass)->hasDatabaseField($properties[1])) {
190
                    $canSort = true;
191
                }
192
            } elseif ($singleton instanceof DataObject) {
193
                if ($singleton->hasDatabaseField($property)) {
194
                    $canSort = true;
195
                }
196
            }
197
        }
198
199
        return $canSort;
200
    }
201
202
    /**
203
     * Format a set of columns, used for headings and row data
204
     *
205
     * @param  int $id The result ID to reference
206
     * @return ArrayList
207
     * @throws RegistryException If parameters are used in column names
208
     */
209
    public function Columns($id = null)
210
    {
211
        $singleton = $this->dataRecord->getDataSingleton();
212
        $columns   = $singleton->summaryFields();
213
        $list      = ArrayList::create();
214
        $result    = null;
215
216
        if ($id) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $id of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
217
            $result = $this->queryList()->byId($id);
0 ignored issues
show
Bug introduced by
The method byId() does not exist on SilverStripe\ORM\SS_List. It seems like you code against a sub-type of said class. However, the method does not exist in SilverStripe\ORM\Sortable or SilverStripe\ORM\Limitable. Are you sure you never get one of those? ( Ignorable by Annotation )

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

217
            $result = $this->queryList()->/** @scrutinizer ignore-call */ byId($id);
Loading history...
218
        }
219
220
        foreach ($columns as $name => $title) {
221
            // Check for unwanted parameters
222
            if (preg_match('/[()]/', $name)) {
223
                throw new RegistryException(_t(
224
                    'SilverStripe\\Registry\\RegistryPageController.UNWANTEDCOLUMNPARAMETERS',
225
                    "Columns do not accept parameters"
226
                ));
227
            }
228
229
            // Get dot deliniated properties
230
            $properties = explode('.', $name);
231
232
            // Increment properties for value
233
            $context = $result;
234
            foreach ($properties as $property) {
235
                if ($context instanceof ViewableData) {
236
                    $context = $context->obj($property);
237
                }
238
            }
239
240
            // Check for link
241
            $link = null;
242
            $useLink = $singleton->config()->get('use_link');
243
            if ($useLink !== false) {
244
                if ($result && $result->hasMethod('Link')) {
245
                    $link = $result->Link();
246
                }
247
            }
248
249
            // Format column
250
            $list->push(ArrayData::create([
251
                'Name' => $name,
252
                'Title' => $title,
253
                'Link' => $link,
254
                'Value' => $context,
255
                'CanSort' => $this->canSortBy($name)
256
            ]));
257
        }
258
        return $list;
259
    }
260
261
    /**
262
     * Exports out all the data for the current search results.
263
     * Sends the data to the browser as a CSV file.
264
     */
265
    public function export($request)
266
    {
267
        $dataClass = $this->dataRecord->getDataClass();
0 ignored issues
show
Unused Code introduced by
The assignment to $dataClass is dead and can be removed.
Loading history...
268
        /** @var DataObject $singleton */
269
        $singleton = $this->dataRecord->getDataSingleton();
270
        $columns = $singleton->summaryFields();
271
272
        // Used for the browser, not stored on the server
273
        $filepath = sprintf('export-%s.csv', date('Y-m-dHis'));
274
275
        // Allocates up to 1M of memory storage to write to, then will fail over to a temporary file on the filesystem
276
        $handle = fopen('php://temp/maxmemory:' . (1024 * 1024), 'w');
277
278
        // put the headers in the first row
279
        fputcsv($handle, $columns);
0 ignored issues
show
Bug introduced by
It seems like $handle can also be of type false; however, parameter $handle of fputcsv() does only seem to accept resource, 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

279
        fputcsv(/** @scrutinizer ignore-type */ $handle, $columns);
Loading history...
280
281
        // put the data in the rows after
282
        foreach ($this->RegistryEntries(false) as $result) {
283
            $item = [];
284
            foreach ($columns as $column => $columnLabel) {
285
                $item[] = $result->$column;
286
            }
287
            fputcsv($handle, $item);
288
        }
289
290
        rewind($handle);
0 ignored issues
show
Bug introduced by
It seems like $handle can also be of type false; however, parameter $handle of rewind() does only seem to accept resource, 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

290
        rewind(/** @scrutinizer ignore-type */ $handle);
Loading history...
291
292
        // if the headers can't be sent (i.e. running a unit test, or something)
293
        // just return the file path so the user can manually download the csv
294
        if (!headers_sent() && $this->config()->get('output_headers')) {
295
            header('Content-Description: File Transfer');
296
            header('Content-Type: application/octet-stream');
297
            header('Content-Disposition: attachment; filename=' . $filepath);
298
            header('Content-Transfer-Encoding: binary');
299
            header('Expires: 0');
300
            header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
301
            header('Pragma: public');
302
            header('Content-Length: ' . fstat($handle)['size']);
0 ignored issues
show
Bug introduced by
It seems like $handle can also be of type false; however, parameter $handle of fstat() does only seem to accept resource, 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

302
            header('Content-Length: ' . fstat(/** @scrutinizer ignore-type */ $handle)['size']);
Loading history...
303
            ob_clean();
304
            flush();
305
306
            echo stream_get_contents($handle);
0 ignored issues
show
Bug introduced by
It seems like $handle can also be of type false; however, parameter $handle of stream_get_contents() does only seem to accept resource, 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

306
            echo stream_get_contents(/** @scrutinizer ignore-type */ $handle);
Loading history...
307
308
            fclose($handle);
0 ignored issues
show
Bug introduced by
It seems like $handle can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, 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

308
            fclose(/** @scrutinizer ignore-type */ $handle);
Loading history...
309
        } else {
310
            $contents = stream_get_contents($handle);
311
            fclose($handle);
312
313
            return $contents;
314
        }
315
    }
316
317
    public function show($request)
318
    {
319
        // If Id is not numeric, then return an error page
320
        if (!is_numeric($request->param('ID'))) {
321
            return $this->httpError(404);
322
        }
323
324
        $entry = DataObject::get_by_id($this->DataClass, $request->param('ID'));
325
326
        if (!$entry || !$entry->exists()) {
0 ignored issues
show
introduced by
$entry is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
327
            return $this->httpError(404);
328
        }
329
330
        return $this->customise([
331
            'Entry' => $entry
332
        ]);
333
    }
334
335
    /**
336
     * Perform a search against the data table.
337
     * @return SS_List
338
     */
339
    protected function queryList()
340
    {
341
        // Sanity check
342
        $dataClass = $this->dataRecord->getDataClass();
343
        if (!$dataClass) {
344
            return ArrayList::create();
345
        }
346
347
        // Setup
348
        $singleton = $this->dataRecord->getDataSingleton();
349
350
        // Create list
351
        $list = $singleton->get();
352
353
        // Setup filters
354
        $filters = [];
355
        foreach ($singleton->config()->get('searchable_fields') as $field) {
356
            $value = $this->getRequest()->getVar(str_replace('.', '_', $field));
357
            if (!$value) {
358
                continue;
359
            }
360
361
            // if the searchable field is a relationship, it must be an exact match (ID match) if not partial is fine.
362
            $matchType = $this->instanceHasRelationship($singleton, $field) ? "ExactMatch" : "PartialMatch";
363
            $filters[$field . ':' . $matchType] = $value;
364
        }
365
        $list = $list->filter($filters);
366
367
        // Sort
368
        $sort = $this->getRequest()->getVar('Sort');
369
        if ($sort) {
370
            $dir = 'ASC';
371
            if ($this->getRequest()->getVar('Dir')) {
372
                $dir = $this->getRequest()->getVar('Dir');
373
            }
374
            if ($this->canSortBy($sort)) {
375
                $list = $list->sort($sort, $dir);
376
            }
377
        }
378
379
        return $list;
380
    }
381
382
    /**
383
     *
384
     * Returns boolean if the fieldname is a relationship on the instance.
385
     *
386
     * @param DataObject $singleton
387
     * @param string $field
388
     *
389
     * @return bool
390
     */
391
    protected function instanceHasRelationship(DataObject $singleton, $field)
392
    {
393
        // Strip ID or .ID off the fieldname, as it's not going to be present in our relationship
394
        $fieldName = mb_substr(preg_replace("/[^a-z]/iu", "", $field), 0, -2, 'utf-8');
395
396
        // check the instances's relationships.
397
        return array_key_exists($fieldName, $singleton->hasOne()) ||
398
            array_key_exists($fieldName, $singleton->hasMany()) ||
399
            array_key_exists($fieldName, $singleton->manyMany());
400
    }
401
402
    /**
403
     * Safely escape a list of "select" candidates for a query
404
     *
405
     * @param array $names List of select fields
406
     * @return array List of names, with each name double quoted
407
     */
408
    protected function escapeSelect($names)
409
    {
410
        return array_map(
411
            function ($var) {
412
                return "\"{$var}\"";
413
            },
414
            $names
415
        );
416
    }
417
418
    /**
419
     * Compiles all available GET variables for the result
420
     * columns into an array. Used internally, not to be
421
     * used directly with the templates or outside classes.
422
     *
423
     * This will NOT escape values to avoid XSS.
424
     *
425
     * @return array
426
     */
427
    protected function queryVars()
428
    {
429
        $resultColumns = $this->dataRecord->getDataSingleton()->getSearchFields();
430
        $columns = [];
431
        foreach ($resultColumns as $field) {
432
            $columns[$field->getName()] = '';
433
        }
434
435
        $arr = array_merge(
436
            $columns,
437
            [
438
                'action_doRegistryFilter' => 'Filter',
439
                'Sort' => '',
440
                'Dir' => ''
441
            ]
442
        );
443
444
        foreach ($arr as $key => $val) {
445
            if (isset($_GET[$key])) {
446
                $arr[$key] = $_GET[$key];
447
            }
448
        }
449
450
        return $arr;
451
    }
452
453
    public function getTemplateList($action)
454
    {
455
        // Add action-specific templates for inheritance chain
456
        $templates = [];
457
        $parentClass = get_class($this);
0 ignored issues
show
Unused Code introduced by
The assignment to $parentClass is dead and can be removed.
Loading history...
458
        if ($action && $action !== 'index') {
459
            $parentClass = get_class($this);
460
            while ($parentClass !== Controller::class) {
461
                $templates[] = strtok($parentClass, '_') . '_' . $action;
462
                $parentClass = get_parent_class($parentClass);
463
            }
464
        }
465
        // Add controller templates for inheritance chain
466
        $parentClass = get_class($this);
467
        while ($parentClass !== Controller::class) {
468
            $templates[] = strtok($parentClass, '_');
469
            $parentClass = get_parent_class($parentClass);
470
        }
471
472
        $templates[] = Controller::class;
473
474
        // remove duplicates
475
        $templates = array_unique($templates);
476
477
        $actionlessTemplates = [];
478
479
        if ($action && $action !== 'index') {
480
            array_unshift($templates, $this->DataClass . '_RegistryPage_' . $action);
481
        }
482
        array_unshift($actionlessTemplates, $this->DataClass . '_RegistryPage');
483
484
        $parentClass = get_class($this->dataRecord);
485
        while ($parentClass !== RegistryPage::class) {
486
            if ($action && $action != 'index') {
487
                array_unshift($templates, $parentClass . '_' . $action);
488
            }
489
            array_unshift($actionlessTemplates, $parentClass);
490
491
            $parentClass = get_parent_class($parentClass);
492
        }
493
494
        $index = 0;
495
        while ($index < count($templates) && $templates[$index] !== RegistryPage::class) {
496
            $index++;
497
        }
498
499
        return array_merge(array_slice($templates, 0, $index), $actionlessTemplates, array_slice($templates, $index));
500
    }
501
502
    /**
503
     * Sanitise a PHP class name for display in URLs etc
504
     *
505
     * @return string
506
     */
507
    public function getClassNameForUrl($className)
508
    {
509
        return str_replace('\\', '-', $className);
510
    }
511
}
512