Passed
Pull Request — master (#47)
by
unknown
02:13
created

RegistryPageController::canSortBy()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 21
c 0
b 0
f 0
rs 7.551
cc 7
eloc 12
nc 6
nop 1
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\HTTP;
8
use SilverStripe\Core\Convert;
9
use SilverStripe\Core\Injector\Injector;
10
use SilverStripe\Forms\FieldList;
11
use SilverStripe\Forms\Form;
12
use SilverStripe\Forms\FormAction;
13
use SilverStripe\Forms\HiddenField;
14
use SilverStripe\ORM\ArrayList;
15
use SilverStripe\ORM\DataObject;
16
use SilverStripe\ORM\PaginatedList;
17
use SilverStripe\ORM\Queries\SQLSelect;
18
use SilverStripe\Registry\Exception\RegistryException;
19
use SilverStripe\View\ArrayData;
20
use SilverStripe\View\ViewableData;
21
22
class RegistryPageController extends PageController
23
{
24
    private static $allowed_actions = [
25
        'RegistryFilterForm',
26
        'show',
27
        'export',
28
    ];
29
30
    /**
31
     * Whether to output headers when sending the export file. This can be disabled for example in unit tests.
32
     *
33
     * @config
34
     * @var bool
35
     */
36
    private static $output_headers = true;
37
38
    /**
39
     * Get all search query vars, compiled into a query string for a URL.
40
     * This will escape all the variables to avoid XSS.
41
     *
42
     * @return string
43
     */
44
    public function AllQueryVars()
45
    {
46
        return Convert::raw2xml(http_build_query($this->queryVars()));
47
    }
48
49
    /**
50
     * Get all search query vars except Sort and Dir, compiled into a query link.
51
     * This will escape all the variables to avoid XSS.
52
     *
53
     * @return string
54
     */
55
    public function QueryLink()
56
    {
57
        $vars = $this->queryVars();
58
        unset($vars['Sort']);
59
        unset($vars['Dir']);
60
61
        return Convert::raw2xml($this->Link('RegistryFilterForm') . '?' . http_build_query($vars));
62
    }
63
64
    public function Sort()
65
    {
66
        return isset($_GET['Sort']) ? $_GET['Sort'] : '';
67
    }
68
69
    /**
70
     * Return the opposite direction from the currently sorted column's direction.
71
     * @return string
72
     */
73
    public function OppositeDirection()
74
    {
75
        // If direction is set, then just reverse it.
76
        $direction = $this->request->getVar('Dir');
77
        if ($direction) {
78
            if ($direction == 'ASC') {
79
                return 'DESC';
80
            }
81
            return 'ASC';
82
        }
83
84
        // If the sort column is set, then we're sorting by ASC (default is omitted)
85
        if ($this->request->getVar('Sort')) {
86
            return 'DESC';
87
        }
88
89
        // Otherwise we're not sorting at all so default to ASC.
90
        return 'ASC';
91
    }
92
93
    public function RegistryFilterForm()
94
    {
95
        $singleton = $this->dataRecord->getDataSingleton();
96
        if (!$singleton) {
97
            return;
98
        }
99
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']),
0 ignored issues
show
Bug introduced by
'Sort' of type string is incompatible with the type array expected by parameter $args of SilverStripe\View\ViewableData::create(). ( Ignorable by Annotation )

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

105
            HiddenField::create(/** @scrutinizer ignore-type */ 'Sort', 'Sort', (!$vars || empty($vars['Sort'])) ? 'ID' : $vars['Sort']),
Loading history...
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
        $form = Form::create($this, 'RegistryFilterForm', $fields, $actions);
0 ignored issues
show
Bug introduced by
$this of type SilverStripe\Registry\RegistryPageController is incompatible with the type array expected by parameter $args of SilverStripe\View\ViewableData::create(). ( Ignorable by Annotation )

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

114
        $form = Form::create(/** @scrutinizer ignore-type */ $this, 'RegistryFilterForm', $fields, $actions);
Loading history...
115
        $form->loadDataFrom($this->request->getVars());
116
        $form->disableSecurityToken();
117
        $form->setFormMethod('get');
118
119
        return $form;
120
    }
121
122
    /**
123
     * Build up search filters from user's search criteria and hand off
124
     * to the {@link query()} method to search against the database.
125
     *
126
     * @param array $data Form request data
127
     * @param Form Form object for submitted form
128
     * @param HTTPRequest
129
     * @return array
130
     */
131
    public function doRegistryFilter($data, $form, $request)
132
    {
133
        // Basic parameters
134
        $parameters = [
135
            'start' => 0,
136
            'Sort' => 'ID',
137
            'Dir' => 'ASC',
138
        ];
139
140
        // Data record-specific parameters
141
        $singleton = $this->dataRecord->getDataSingleton();
142
        if ($singleton) {
143
            $fields = $singleton->getSearchFields();
144
            if ($fields) {
145
                foreach ($fields as $field) {
146
                    $parameters[$field->Name] = '';
147
                }
148
            }
149
        }
150
151
        // Read them from the request
152
        foreach ($parameters as $key => $default) {
153
            $value = $this->request->getVar($key);
154
            if (!$value || $value == $default) {
155
                unset($parameters[$key]);
156
            } else {
157
                $parameters[$key] = $value;
158
            }
159
        }
160
161
        // Link back to this page with the relevant parameters.
162
        $link = $this->AbsoluteLink();
163
        foreach ($parameters as $key => $value) {
164
            $link = HTTP::setGetVar($key, $value, $link, '&');
165
        }
166
        $this->redirect($link);
167
    }
168
169
    public function doRegistryFilterReset($data, $form, $request)
170
    {
171
        // Link back to this page with no relevant parameters.
172
        $this->redirect($this->AbsoluteLink());
173
    }
174
175
    public function RegistryEntries($paginated = true)
176
    {
177
        $variables = $this->request->getVars();
178
        $singleton = $this->dataRecord->getDataSingleton();
179
180
        // Pagination
181
        $start = isset($variables['start']) ? (int)$variables['start'] : 0;
182
183
        // Ordering
184
        $sort = isset($variables['Sort']) && $variables['Sort'] ? Convert::raw2sql($variables['Sort']) : 'ID';
185
        if ($this->canSortBy($sort)) {
0 ignored issues
show
Bug introduced by
It seems like $sort can also be of type array; however, parameter $property of SilverStripe\Registry\Re...Controller::canSortBy() 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

185
        if ($this->canSortBy(/** @scrutinizer ignore-type */ $sort)) {
Loading history...
186
            $sort = 'ID';
187
        }
188
        $direction = (!empty($variables['Dir']) && in_array($variables['Dir'], ['ASC', 'DESC']))
189
            ? $variables['Dir']
190
            : 'ASC';
191
        $orderby = ["\"{$sort}\"" => $direction];
192
193
        // Filtering
194
        $where = [];
195
        if ($singleton) {
196
            foreach ($singleton->getSearchFields() as $field) {
197
                if (!empty($variables[$field->getName()])) {
198
                    $where[] = sprintf(
199
                        '"%s" LIKE \'%%%s%%\'',
200
                        $field->getName(),
201
                        Convert::raw2sql($variables[$field->getName()])
0 ignored issues
show
Bug introduced by
It seems like SilverStripe\Core\Conver...les[$field->getName()]) can also be of type array; however, parameter $args of sprintf() 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

201
                        /** @scrutinizer ignore-type */ Convert::raw2sql($variables[$field->getName()])
Loading history...
202
                    );
203
                }
204
            }
205
        }
206
207
        return $this->queryList($where, $orderby, $start, $this->dataRecord->getPageLength(), $paginated);
208
    }
209
210
    /**
211
     * Loosely check if the record can be sorted by a property
212
     * @param  string $property
213
     * @return boolean
214
     */
215
    public function canSortBy($property)
216
    {
217
        $canSort = false;
218
        $singleton = $this->dataRecord->getDataSingleton();
219
220
        if ($singleton) {
221
            $properties = explode('.', $property);
222
223
            $relationClass = $singleton->getRelationClass($properties[0]);
224
            if ($relationClass) {
225
                if (count($properties) <= 2 && singleton($relationClass)->hasDatabaseField($properties[1])) {
226
                    $canSort = true;
227
                }
228
            } elseif ($singleton instanceof DataObject) {
229
                if ($singleton->hasDatabaseField($property)) {
230
                    $canSort = true;
231
                }
232
            }
233
        }
234
235
        return $canSort;
236
    }
237
238
    /**
239
     * Format a set of columns, used for headings and row data
240
     * @param  ViewabledData $result The row context
0 ignored issues
show
Bug introduced by
The type SilverStripe\Registry\ViewabledData 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...
241
     * @return ArrayList
242
     */
243
    public function columns($result = null)
244
    {
245
        $singleton = $this->dataRecord->getDataSingleton();
246
        $columns = $singleton->summaryFields();
247
        $list = ArrayList::create();
248
249
        foreach ($columns as $name => $title) {
250
            // Check for unwanted parameters
251
            if (preg_match('/[()]/', $name)) {
252
                throw new RegistryException(_t(
253
                    'SilverStripe\\Registry\\RegistryPageController.UNWANTEDCOLUMNPARAMETERS',
254
                    "Columns do not accept parameters"
255
                ));
256
            }
257
258
            // Get dot deliniated properties
259
            $properties = explode('.', $name);
260
261
            // Increment properties for value
262
            $context = $result;
263
            foreach ($properties as $property) {
264
                if ($context instanceof ViewableData) {
265
                    $context = $context->obj($property);
266
                }
267
            }
268
269
            // Check for link
270
            $link = null;
271
            $useLink = $singleton->config()->get('use_link');
272
            if ($useLink !== false) {
273
                if ($result && $result->hasMethod('link')) {
274
                    $link = $result->link();
275
                }
276
            }
277
278
            // Format column
279
            $list->push(ArrayData::create([
280
                'Name' => $name,
281
                'Title' => $title,
282
                'Link' => $link,
283
                'Value' => isset($context) ? $context : null,
284
                'CanSort' => $this->canSortBy($name)
285
            ]));
286
        }
287
        return $list;
288
    }
289
290
    /**
291
     * Exports out all the data for the current search results.
292
     * Sends the data to the browser as a CSV file.
293
     */
294
    public function export($request)
295
    {
296
        $dataClass = $this->dataRecord->getDataClass();
0 ignored issues
show
Unused Code introduced by
The assignment to $dataClass is dead and can be removed.
Loading history...
297
        $resultColumns = $this->dataRecord->getDataSingleton()->fieldLabels();
298
299
        // Used for the browser, not stored on the server
300
        $filepath = sprintf('export-%s.csv', date('Y-m-dHis'));
301
302
        // Allocates up to 1M of memory storage to write to, then will fail over to a temporary file on the filesystem
303
        $handle = fopen('php://temp/maxmemory:' . (1024 * 1024), 'w');
304
305
        $cols = array_keys($resultColumns);
306
307
        // put the headers in the first row
308
        fputcsv($handle, $cols);
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

308
        fputcsv(/** @scrutinizer ignore-type */ $handle, $cols);
Loading history...
309
310
        // put the data in the rows after
311
        foreach ($this->RegistryEntries(false) as $result) {
312
            $item = [];
313
            foreach ($cols as $col) {
314
                $item[] = $result->$col;
315
            }
316
            fputcsv($handle, $item);
317
        }
318
319
        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

319
        rewind(/** @scrutinizer ignore-type */ $handle);
Loading history...
320
321
        // if the headers can't be sent (i.e. running a unit test, or something)
322
        // just return the file path so the user can manually download the csv
323
        if (!headers_sent() && $this->config()->get('output_headers')) {
324
            header('Content-Description: File Transfer');
325
            header('Content-Type: application/octet-stream');
326
            header('Content-Disposition: attachment; filename=' . $filepath);
327
            header('Content-Transfer-Encoding: binary');
328
            header('Expires: 0');
329
            header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
330
            header('Pragma: public');
331
            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

331
            header('Content-Length: ' . fstat(/** @scrutinizer ignore-type */ $handle)['size']);
Loading history...
332
            ob_clean();
333
            flush();
334
335
            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

335
            echo stream_get_contents(/** @scrutinizer ignore-type */ $handle);
Loading history...
336
337
            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

337
            fclose(/** @scrutinizer ignore-type */ $handle);
Loading history...
338
        } else {
339
            $contents = stream_get_contents($handle);
340
            fclose($handle);
341
342
            return $contents;
343
        }
344
    }
345
346
    public function show($request)
347
    {
348
        // If Id is not numeric, then return an error page
349
        if (!is_numeric($request->param('ID'))) {
350
            return $this->httpError(404);
351
        }
352
353
        $entry = DataObject::get_by_id($this->DataClass, $request->param('ID'));
354
355
        if (!($entry && $entry->exists())) {
356
            return $this->httpError(404);
357
        }
358
359
        return $this->customise([
360
            'Entry' => $entry
361
        ]);
362
    }
363
364
    /**
365
     * Perform a search against the data table.
366
     *
367
     * @param array $where Array of strings to add into the WHERE clause
368
     * @param array $orderby Array of column as key, to direction as value to add into the ORDER BY clause
369
     * @param string|int $start Record to start at (for paging)
370
     * @param string|int $pageLength Number of results per page (for paging)
371
     * @param boolean $paged Paged results or not?
372
     * @return ArrayList|PaginatedList
373
     */
374
    protected function queryList(array $where, array $orderby, $start, $pageLength, $paged = true)
375
    {
376
        $dataClass = $this->dataRecord->getDataClass();
377
        if (!$dataClass) {
378
            return PaginatedList::create(ArrayList::create());
379
        }
380
381
        $tableName = DataObject::getSchema()->tableName($dataClass);
382
383
        $summarisedModel = $this->dataRecord->getDataSingleton();
384
        $resultColumns = $summarisedModel->summaryFields();
0 ignored issues
show
Unused Code introduced by
The assignment to $resultColumns is dead and can be removed.
Loading history...
385
386
        // Utilise DataObject::$searchable_fields
387
        $resultDBOnlyColumns = [];
388
        $fields = $summarisedModel->config()->get('searchable_fields');
389
        foreach ($fields as $field) {
390
            $resultDBOnlyColumns[$field] = $field;
391
        }
392
393
        $resultDBOnlyColumns['ID'] = 'ID';
394
        $results = ArrayList::create();
395
396
        $query = SQLSelect::create();
397
        $query
398
            ->setSelect($this->escapeSelect(array_keys($resultDBOnlyColumns)))
399
            ->setFrom('"' . $tableName . '"');
400
        $query->addWhere($where);
401
        $query->addOrderBy($orderby);
402
        $query->setConnective('AND');
403
404
        if ($paged) {
405
            $query->setLimit($pageLength, $start);
0 ignored issues
show
Bug introduced by
It seems like $start can also be of type string; however, parameter $offset of SilverStripe\ORM\Queries\SQLSelect::setLimit() does only seem to accept integer, 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

405
            $query->setLimit($pageLength, /** @scrutinizer ignore-type */ $start);
Loading history...
406
        }
407
408
        foreach ($query->execute() as $record) {
409
            $result = Injector::inst()->create($dataClass, $record);
410
            // we attach Columns here so the template can loop through them on each result
411
            $result->Columns = $this->Columns($result);
412
            $results->push($result);
413
        }
414
415
        if ($paged) {
0 ignored issues
show
introduced by
The condition $paged is always true.
Loading history...
416
            $list = PaginatedList::create($results);
417
            $list->setPageStart($start);
418
            $list->setPageLength($pageLength);
419
            $list->setTotalItems($query->unlimitedRowCount());
420
            $list->setLimitItems(false);
421
        } else {
422
            $list = $results;
423
        }
424
425
        return $list;
426
    }
427
428
    /**
429
     * Safely escape a list of "select" candidates for a query
430
     *
431
     * @param array $names List of select fields
432
     * @return array List of names, with each name double quoted
433
     */
434
    protected function escapeSelect($names)
435
    {
436
        return array_map(
437
            function ($var) {
438
                return "\"{$var}\"";
439
            },
440
            $names
441
        );
442
    }
443
444
    /**
445
     * Compiles all available GET variables for the result
446
     * columns into an array. Used internally, not to be
447
     * used directly with the templates or outside classes.
448
     *
449
     * This will NOT escape values to avoid XSS.
450
     *
451
     * @return array
452
     */
453
    protected function queryVars()
454
    {
455
        $resultColumns = $this->dataRecord->getDataSingleton()->getSearchFields();
456
        $columns = [];
457
        foreach ($resultColumns as $field) {
458
            $columns[$field->getName()] = '';
459
        }
460
461
        $arr = array_merge(
462
            $columns,
463
            [
464
                'action_doRegistryFilter' => 'Filter',
465
                'Sort' => '',
466
                'Dir' => ''
467
            ]
468
        );
469
470
        foreach ($arr as $key => $val) {
471
            if (isset($_GET[$key])) {
472
                $arr[$key] = $_GET[$key];
473
            }
474
        }
475
476
        return $arr;
477
    }
478
479
    public function getTemplateList($action)
480
    {
481
        // Add action-specific templates for inheritance chain
482
        $templates = [];
483
        $parentClass = get_class($this);
0 ignored issues
show
Unused Code introduced by
The assignment to $parentClass is dead and can be removed.
Loading history...
484
        if ($action && $action !== 'index') {
485
            $parentClass = get_class($this);
486
            while ($parentClass !== Controller::class) {
487
                $templates[] = strtok($parentClass, '_') . '_' . $action;
488
                $parentClass = get_parent_class($parentClass);
489
            }
490
        }
491
        // Add controller templates for inheritance chain
492
        $parentClass = get_class($this);
493
        while ($parentClass !== Controller::class) {
494
            $templates[] = strtok($parentClass, '_');
495
            $parentClass = get_parent_class($parentClass);
496
        }
497
498
        $templates[] = Controller::class;
499
500
        // remove duplicates
501
        $templates = array_unique($templates);
502
503
        $actionlessTemplates = [];
504
505
        if ($action && $action !== 'index') {
506
            array_unshift($templates, $this->DataClass . '_RegistryPage_' . $action);
507
        }
508
        array_unshift($actionlessTemplates, $this->DataClass . '_RegistryPage');
509
510
        $parentClass = get_class($this->dataRecord);
511
        while ($parentClass !== RegistryPage::class) {
512
            if ($action && $action != 'index') {
513
                array_unshift($templates, $parentClass . '_' . $action);
514
            }
515
            array_unshift($actionlessTemplates, $parentClass);
516
517
            $parentClass = get_parent_class($parentClass);
518
        }
519
520
        $index = 0;
521
        while ($index < count($templates) && $templates[$index] !== RegistryPage::class) {
522
            $index++;
523
        }
524
525
        return array_merge(array_slice($templates, 0, $index), $actionlessTemplates, array_slice($templates, $index));
526
    }
527
528
    /**
529
     * Sanitise a PHP class name for display in URLs etc
530
     *
531
     * @return string
532
     */
533
    public function getClassNameForUrl($className)
534
    {
535
        return str_replace('\\', '-', $className);
536
    }
537
}
538