Completed
Push — master ( 3a40ab...36f97a )
by
unknown
12s
created

RegistryPageController::queryList()   B

Complexity

Conditions 7
Paths 13

Size

Total Lines 39
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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

277
        fputcsv(/** @scrutinizer ignore-type */ $handle, $cols);
Loading history...
278
279
        // put the data in the rows after
280
        foreach ($this->RegistryEntries(false) as $result) {
281
            $item = [];
282
            foreach ($cols as $col) {
283
                $item[] = $result->$col;
284
            }
285
            fputcsv($handle, $item);
286
        }
287
288
        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

288
        rewind(/** @scrutinizer ignore-type */ $handle);
Loading history...
289
290
        // if the headers can't be sent (i.e. running a unit test, or something)
291
        // just return the file path so the user can manually download the csv
292
        if (!headers_sent() && $this->config()->get('output_headers')) {
293
            header('Content-Description: File Transfer');
294
            header('Content-Type: application/octet-stream');
295
            header('Content-Disposition: attachment; filename=' . $filepath);
296
            header('Content-Transfer-Encoding: binary');
297
            header('Expires: 0');
298
            header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
299
            header('Pragma: public');
300
            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

300
            header('Content-Length: ' . fstat(/** @scrutinizer ignore-type */ $handle)['size']);
Loading history...
301
            ob_clean();
302
            flush();
303
304
            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

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

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