Passed
Pull Request — 4.8 (#10055)
by Steve
09:30
created

SearchContext   B

Complexity

Total Complexity 45

Size/Duplication

Total Lines 370
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 100
c 1
b 0
f 0
dl 0
loc 370
rs 8.8
wmc 45

18 Methods

Rating   Name   Duplication   Size   Complexity  
A getSearchFields() 0 3 2
A __construct() 0 5 3
A applyBaseTableFields() 0 11 2
A getFilter() 0 6 2
A setFilters() 0 3 1
A getFilters() 0 3 1
A addFilter() 0 3 1
A getSearchParams() 0 3 1
A removeFilterByName() 0 3 1
A getResults() 0 6 1
B getSummary() 0 38 8
A setSearchParams() 0 9 2
A getFields() 0 3 1
A setFields() 0 3 1
A removeFieldByName() 0 3 1
A addField() 0 3 1
C getQuery() 0 66 15
A clearEmptySearchFields() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like SearchContext 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 SearchContext, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\ORM\Search;
4
5
use SilverStripe\Control\HTTPRequest;
6
use SilverStripe\Core\ClassInfo;
7
use SilverStripe\Core\Injector\Injectable;
8
use SilverStripe\Forms\FieldList;
9
use SilverStripe\Forms\FormField;
10
use SilverStripe\ORM\DataObject;
11
use SilverStripe\ORM\DataList;
12
use SilverStripe\ORM\Filters\SearchFilter;
13
use SilverStripe\ORM\ArrayList;
14
use SilverStripe\View\ArrayData;
15
use SilverStripe\Forms\SelectField;
16
use SilverStripe\Forms\CheckboxField;
17
use InvalidArgumentException;
18
use Exception;
19
20
/**
21
 * Manages searching of properties on one or more {@link DataObject}
22
 * types, based on a given set of input parameters.
23
 * SearchContext is intentionally decoupled from any controller-logic,
24
 * it just receives a set of search parameters and an object class it acts on.
25
 *
26
 * The default output of a SearchContext is either a {@link SQLSelect} object
27
 * for further refinement, or a {@link SS_List} that can be used to display
28
 * search results, e.g. in a {@link TableListField} instance.
29
 *
30
 * In case you need multiple contexts, consider namespacing your request parameters
31
 * by using {@link FieldList->namespace()} on the $fields constructor parameter.
32
 *
33
 * Each DataObject subclass can have multiple search contexts for different cases,
34
 * e.g. for a limited frontend search and a fully featured backend search.
35
 * By default, you can use {@link DataObject->getDefaultSearchContext()} which is automatically
36
 * scaffolded. It uses {@link DataObject::$searchable_fields} to determine which fields
37
 * to include.
38
 *
39
 * @see http://doc.silverstripe.com/doku.php?id=searchcontext
40
 */
41
class SearchContext
42
{
43
    use Injectable;
44
45
    /**
46
     * DataObject subclass to which search parameters relate to.
47
     * Also determines as which object each result is provided.
48
     *
49
     * @var string
50
     */
51
    protected $modelClass;
52
53
    /**
54
     * FormFields mapping to {@link DataObject::$db} properties
55
     * which are supposed to be searchable.
56
     *
57
     * @var FieldList
58
     */
59
    protected $fields;
60
61
    /**
62
     * Array of {@link SearchFilter} subclasses.
63
     *
64
     * @var SearchFilter[]
65
     */
66
    protected $filters;
67
68
    /**
69
     * Key/value pairs of search fields to search terms
70
     *
71
     * @var array
72
     */
73
    protected $searchParams = [];
74
75
    /**
76
     * The logical connective used to join WHERE clauses. Defaults to AND.
77
     * @var string
78
     */
79
    public $connective = 'AND';
80
81
    /**
82
     * A key value pair of values that should be searched for.
83
     * The keys should match the field names specified in {@link self::$fields}.
84
     * Usually these values come from a submitted searchform
85
     * in the form of a $_REQUEST object.
86
     * CAUTION: All values should be treated as insecure client input.
87
     *
88
     * @param string $modelClass The base {@link DataObject} class that search properties related to.
89
     *                      Also used to generate a set of result objects based on this class.
90
     * @param FieldList $fields Optional. FormFields mapping to {@link DataObject::$db} properties
91
     *                      which are to be searched. Derived from modelclass using
92
     *                      {@link DataObject::scaffoldSearchFields()} if left blank.
93
     * @param array $filters Optional. Derived from modelclass if left blank
94
     */
95
    public function __construct($modelClass, $fields = null, $filters = null)
96
    {
97
        $this->modelClass = $modelClass;
98
        $this->fields = ($fields) ? $fields : new FieldList();
99
        $this->filters = ($filters) ? $filters : [];
100
    }
101
102
    /**
103
     * Returns scaffolded search fields for UI.
104
     *
105
     * @return FieldList
106
     */
107
    public function getSearchFields()
108
    {
109
        return ($this->fields) ? $this->fields : singleton($this->modelClass)->scaffoldSearchFields();
110
        // $this->fields is causing weirdness, so we ignore for now, using the default scaffolding
111
        //return singleton($this->modelClass)->scaffoldSearchFields();
112
    }
113
114
    /**
115
     * @todo move to SQLSelect
116
     * @todo fix hack
117
     */
118
    protected function applyBaseTableFields()
119
    {
120
        $classes = ClassInfo::dataClassesFor($this->modelClass);
121
        $baseTable = DataObject::getSchema()->baseDataTable($this->modelClass);
122
        $fields = ["\"{$baseTable}\".*"];
123
        if ($this->modelClass != $classes[0]) {
124
            $fields[] = '"' . $classes[0] . '".*';
125
        }
126
        //$fields = array_keys($model->db());
127
        $fields[] = '"' . $classes[0] . '".\"ClassName\" AS "RecordClassName"';
128
        return $fields;
129
    }
130
131
    /**
132
     * Returns a SQL object representing the search context for the given
133
     * list of query parameters.
134
     *
135
     * @param array $searchParams Map of search criteria, mostly taken from $_REQUEST.
136
     *  If a filter is applied to a relationship in dot notation,
137
     *  the parameter name should have the dots replaced with double underscores,
138
     *  for example "Comments__Name" instead of the filter name "Comments.Name".
139
     * @param array|bool|string $sort Database column to sort on.
140
     *  Falls back to {@link DataObject::$default_sort} if not provided.
141
     * @param array|bool|string $limit
142
     * @param DataList $existingQuery
143
     * @param bool $disjunctive Use OR to connect WHERE clauses between fields instead of AND
144
     * @return DataList
145
     * @throws Exception
146
     */
147
    public function getQuery($searchParams, $sort = false, $limit = false, $existingQuery = null, $disjunctive = false)
148
    {
149
        /** DataList $query */
150
        $query = null;
151
        if ($existingQuery) {
152
            if (!($existingQuery instanceof DataList)) {
0 ignored issues
show
introduced by
$existingQuery is always a sub-type of SilverStripe\ORM\DataList.
Loading history...
153
                throw new InvalidArgumentException("existingQuery must be DataList");
154
            }
155
            if ($existingQuery->dataClass() != $this->modelClass) {
156
                throw new InvalidArgumentException("existingQuery's dataClass is " . $existingQuery->dataClass()
157
                    . ", $this->modelClass expected.");
158
            }
159
            $query = $existingQuery;
160
        } else {
161
            $query = DataList::create($this->modelClass);
162
        }
163
164
        if (is_array($limit)) {
165
            $query = $query->limit(
166
                isset($limit['limit']) ? $limit['limit'] : null,
167
                isset($limit['start']) ? $limit['start'] : null
168
            );
169
        } else {
170
            $query = $query->limit($limit);
0 ignored issues
show
Bug introduced by
$limit of type boolean|string is incompatible with the type integer expected by parameter $limit of SilverStripe\ORM\DataList::limit(). ( Ignorable by Annotation )

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

170
            $query = $query->limit(/** @scrutinizer ignore-type */ $limit);
Loading history...
171
        }
172
173
        /** @var DataList $query */
174
        $query = $query->sort($sort);
175
        $this->setSearchParams($searchParams);
176
177
        if ($disjunctive) {
178
            // WHERE .. OR
179
            $disjunctiveParams = $this->searchParams;
180
            foreach ($this->searchParams as $key => $value) {
181
                if ($filter = $this->getFilter($key)) {
182
                    // TODO: replace with some sort of reverse lookup on DataListFilter config in case someone
183
                    // adds their own filter type that doesn't end in 'Filter'
184
                    $parts = explode('\\', get_class($filter));
185
                    $classNoNamespace = end($parts);
186
                    if (preg_match('#^([A-Za-z0-9]+)Filter$#', $classNoNamespace, $matches)) {
187
                        $modifier = $matches[1]; // e.g. PartialMatch
188
                        $disjunctiveParams[$key . ':' . $modifier] = $value;
189
                        unset($disjunctiveParams[$key]);
190
                    }
191
                }
192
            }
193
            $query = $query->filterAny($disjunctiveParams);
194
        } else {
195
            // WHERE .. AND
196
            foreach ($this->searchParams as $key => $value) {
197
                $key = str_replace('__', '.', $key);
198
                if ($filter = $this->getFilter($key)) {
199
                    $filter->setModel($this->modelClass);
200
                    $filter->setValue($value);
201
                    if (!$filter->isEmpty()) {
202
                        $query = $query->alterDataQuery([$filter, 'apply']);
203
                    }
204
                }
205
            }
206
        }
207
208
        if ($this->connective != "AND") {
209
            throw new Exception("SearchContext connective '$this->connective' not supported after ORM-rewrite.");
210
        }
211
212
        return $query;
213
    }
214
215
    /**
216
     * Returns a result set from the given search parameters.
217
     *
218
     * @todo rearrange start and limit params to reflect DataObject
219
     *
220
     * @param array $searchParams
221
     * @param array|bool|string $sort
222
     * @param array|bool|string $limit
223
     * @return DataList
224
     * @throws Exception
225
     */
226
    public function getResults($searchParams, $sort = false, $limit = false)
227
    {
228
        $searchParams = array_filter((array)$searchParams, [$this, 'clearEmptySearchFields']);
229
230
        // getQuery actually returns a DataList
231
        return $this->getQuery($searchParams, $sort, $limit);
232
    }
233
234
    /**
235
     * Callback map function to filter fields with empty values from
236
     * being included in the search expression.
237
     *
238
     * @param mixed $value
239
     * @return boolean
240
     */
241
    public function clearEmptySearchFields($value)
242
    {
243
        return ($value != '');
244
    }
245
246
    /**
247
     * Accessor for the filter attached to a named field.
248
     *
249
     * @param string $name
250
     * @return SearchFilter
251
     */
252
    public function getFilter($name)
253
    {
254
        if (isset($this->filters[$name])) {
255
            return $this->filters[$name];
256
        } else {
257
            return null;
258
        }
259
    }
260
261
    /**
262
     * Get the map of filters in the current search context.
263
     *
264
     * @return SearchFilter[]
265
     */
266
    public function getFilters()
267
    {
268
        return $this->filters;
269
    }
270
271
    /**
272
     * Overwrite the current search context filter map.
273
     *
274
     * @param array $filters
275
     */
276
    public function setFilters($filters)
277
    {
278
        $this->filters = $filters;
279
    }
280
281
    /**
282
     * Adds a instance of {@link SearchFilter}.
283
     *
284
     * @param SearchFilter $filter
285
     */
286
    public function addFilter($filter)
287
    {
288
        $this->filters[$filter->getFullName()] = $filter;
289
    }
290
291
    /**
292
     * Removes a filter by name.
293
     *
294
     * @param string $name
295
     */
296
    public function removeFilterByName($name)
297
    {
298
        unset($this->filters[$name]);
299
    }
300
301
    /**
302
     * Get the list of searchable fields in the current search context.
303
     *
304
     * @return FieldList
305
     */
306
    public function getFields()
307
    {
308
        return $this->fields;
309
    }
310
311
    /**
312
     * Apply a list of searchable fields to the current search context.
313
     *
314
     * @param FieldList $fields
315
     */
316
    public function setFields($fields)
317
    {
318
        $this->fields = $fields;
319
    }
320
321
    /**
322
     * Adds a new {@link FormField} instance.
323
     *
324
     * @param FormField $field
325
     */
326
    public function addField($field)
327
    {
328
        $this->fields->push($field);
329
    }
330
331
    /**
332
     * Removes an existing formfield instance by its name.
333
     *
334
     * @param string $fieldName
335
     */
336
    public function removeFieldByName($fieldName)
337
    {
338
        $this->fields->removeByName($fieldName);
339
    }
340
341
    /**
342
     * Set search param values
343
     *
344
     * @param array|HTTPRequest $searchParams
345
     * @return $this
346
     */
347
    public function setSearchParams($searchParams)
348
    {
349
        // hack to work with $searchParams when it's an Object
350
        if ($searchParams instanceof HTTPRequest) {
351
            $this->searchParams = $searchParams->getVars();
352
        } else {
353
            $this->searchParams = $searchParams;
354
        }
355
        return $this;
356
    }
357
358
    /**
359
     * @return array
360
     */
361
    public function getSearchParams()
362
    {
363
        return $this->searchParams;
364
    }
365
366
    /**
367
     * Gets a list of what fields were searched and the values provided
368
     * for each field. Returns an ArrayList of ArrayData, suitable for
369
     * rendering on a template.
370
     *
371
     * @return ArrayList
372
     */
373
    public function getSummary()
374
    {
375
        $list = ArrayList::create();
376
        foreach ($this->searchParams as $searchField => $searchValue) {
377
            if (empty($searchValue)) {
378
                continue;
379
            }
380
            $filter = $this->getFilter($searchField);
381
            if (!$filter) {
382
                continue;
383
            }
384
385
            $field = $this->fields->fieldByName($filter->getFullName());
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $field is correct as $this->fields->fieldByNa...$filter->getFullName()) targeting SilverStripe\Forms\FieldList::fieldByName() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
386
            if (!$field) {
387
                continue;
388
            }
389
390
            // For dropdowns, checkboxes, etc, get the value that was presented to the user
391
            // e.g. not an ID
392
            if ($field instanceof SelectField) {
393
                $source = $field->getSource();
394
                if (isset($source[$searchValue])) {
395
                    $searchValue = $source[$searchValue];
396
                }
397
            } else {
398
                // For checkboxes, it suffices to simply include the field in the list, since it's binary
399
                if ($field instanceof CheckboxField) {
400
                    $searchValue = null;
401
                }
402
            }
403
404
            $list->push(ArrayData::create([
405
                'Field' => $field->Title(),
406
                'Value' => $searchValue,
407
            ]));
408
        }
409
410
        return $list;
411
    }
412
}
413