Passed
Pull Request — 4 (#10382)
by Guy
07:28
created

SearchContext::search()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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

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

149
        if (/** @scrutinizer ignore-deprecated */ $this->connective != "AND") {

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
150
            throw new Exception("SearchContext connective '$this->connective' not supported after ORM-rewrite.");
0 ignored issues
show
Deprecated Code introduced by
The property SilverStripe\ORM\Search\SearchContext::$connective has been deprecated: 5.0 ( Ignorable by Annotation )

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

150
            throw new Exception("SearchContext connective '/** @scrutinizer ignore-deprecated */ $this->connective' not supported after ORM-rewrite.");

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
151
        }
152
        $this->setSearchParams($searchParams);
153
        $query = $this->prepareQuery($sort, $limit, $existingQuery);
154
        return $this->search($query);
155
    }
156
157
    /**
158
     * Perform a search on the passed DataList based on $this->searchParams.
159
     */
160
    protected function search(DataList $query): DataList
161
    {
162
        /** @var DataObject $modelObj */
163
        $modelObj = Injector::inst()->create($this->modelClass);
164
        $searchableFields = $modelObj->searchableFields();
165
        foreach ($this->searchParams as $searchField => $searchTerms) {
166
            $searchField = str_replace('__', '.', $searchField ?? '');
167
            if ($searchField !== '' && $searchField === $modelObj->primarySearchFieldName()) {
168
                $query = $this->primaryFieldSearch($query, $searchableFields, $searchTerms);
169
            } else {
170
                $query = $this->individualFieldSearch($query, $searchableFields, $searchField, $searchTerms);
171
            }
172
        }
173
        return $query;
174
    }
175
176
    /**
177
     * Prepare the query to begin searching
178
     *
179
     * @param array|bool|string $sort Database column to sort on.
180
     * @param array|bool|string $limit
181
     */
182
    protected function prepareQuery($sort, $limit, ?DataList $existingQuery): DataList
183
    {
184
        $query = null;
185
        if ($existingQuery) {
186
            if (!($existingQuery instanceof DataList)) {
0 ignored issues
show
introduced by
$existingQuery is always a sub-type of SilverStripe\ORM\DataList.
Loading history...
187
                throw new InvalidArgumentException("existingQuery must be DataList");
188
            }
189
            if ($existingQuery->dataClass() != $this->modelClass) {
190
                throw new InvalidArgumentException("existingQuery's dataClass is " . $existingQuery->dataClass()
191
                    . ", $this->modelClass expected.");
192
            }
193
            $query = $existingQuery;
194
        } else {
195
            $query = DataList::create($this->modelClass);
196
        }
197
198
        if (is_array($limit)) {
199
            $query = $query->limit(
200
                isset($limit['limit']) ? $limit['limit'] : null,
201
                isset($limit['start']) ? $limit['start'] : null
202
            );
203
        } else {
204
            $query = $query->limit($limit);
205
        }
206
207
        return $query->sort($sort);
208
    }
209
210
    /**
211
     * Use the global primary search for searching across multiple fields
212
     *
213
     * @param string|array $searchTerms
214
     */
215
    protected function primaryFieldSearch(DataList $query, array $searchableFields, $searchTerms): DataList
216
    {
217
        $context = $this;
218
        $modelClass = $this->modelClass;
219
        return $query->alterDataQuery(function (DataQuery $dataQuery) use ($context, $modelClass, $searchableFields, $searchTerms) {
220
            $subGroup = $dataQuery->disjunctiveGroup();
221
            $formFields = $context->getSearchFields();
222
            foreach ($searchableFields as $field => $spec) {
223
                $formFieldName = str_replace('.', '__', $field);
224
                // Only apply filter if the field is allowed to be primary and is backed by a form field.
225
                // Otherwise we could be dealing with, for example, a DataObject which implements scaffoldSearchField
226
                // to provide some unexpected field name, where the below would result in a DatabaseException.
227
                if ((!isset($spec['primary']) || $spec['primary'])
228
                    && ($formFields->fieldByName($formFieldName) || $formFields->dataFieldByName($formFieldName))
0 ignored issues
show
Bug introduced by
Are you sure the usage of $formFields->fieldByName($formFieldName) 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 used.

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

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

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

Loading history...
229
                    && $filter = $context->getFilter($field)
230
                ) {
231
                    $filter->setModel($modelClass);
232
                    $filter->setValue($searchTerms);
233
                    $context->applyFilter($filter, $subGroup, $spec);
234
                }
235
            }
236
        });
237
    }
238
239
    /**
240
     * Search against a single field
241
     *
242
     * @param string|array $searchTerms
243
     */
244
    protected function individualFieldSearch(DataList $query, array $searchableFields, string $searchField, $searchTerms): DataList
245
    {
246
        if ($filter = $this->getFilter($searchField)) {
247
            $filter->setModel($this->modelClass);
248
            $filter->setValue($searchTerms);
249
            $searchableFieldSpec = $searchableFields[$searchField];
250
            return $query->alterDataQuery(function ($dataQuery) use ($filter, $searchableFieldSpec) {
251
                $this->applyFilter($filter, $dataQuery, $searchableFieldSpec);
252
            });
253
        }
254
        return $query;
255
    }
256
257
    /**
258
     * Apply a SearchFilter to a DataQuery for a given field's specifications
259
     */
260
    public function applyFilter(SearchFilter $filter, DataQuery $dataQuery, array $searchableFieldSpec): void
261
    {
262
        if (!$filter->isEmpty()) {
263
            if (isset($searchableFieldSpec['match_any'])) {
264
                $searchFields = $searchableFieldSpec['match_any'];
265
                $filterClass = get_class($filter);
266
                $value = $filter->getValue();
267
                $modifiers = $filter->getModifiers();
268
                $subGroup = $dataQuery->disjunctiveGroup();
269
                foreach ($searchFields as $matchField) {
270
                    /** @var SearchFilter $filterClass */
271
                    $filter = new $filterClass($matchField, $value, $modifiers);
272
                    $filter->apply($subGroup);
273
                }
274
            } else {
275
                $filter->apply($dataQuery);
276
            }
277
        }
278
    }
279
280
    /**
281
     * Returns a result set from the given search parameters.
282
     *
283
     * @todo rearrange start and limit params to reflect DataObject
284
     *
285
     * @param array $searchParams
286
     * @param array|bool|string $sort
287
     * @param array|bool|string $limit
288
     * @return DataList
289
     * @throws Exception
290
     */
291
    public function getResults($searchParams, $sort = false, $limit = false)
292
    {
293
        $searchParams = array_filter((array)$searchParams, [$this, 'clearEmptySearchFields']);
294
295
        // getQuery actually returns a DataList
296
        return $this->getQuery($searchParams, $sort, $limit);
297
    }
298
299
    /**
300
     * Callback map function to filter fields with empty values from
301
     * being included in the search expression.
302
     *
303
     * @param mixed $value
304
     * @return boolean
305
     */
306
    public function clearEmptySearchFields($value)
307
    {
308
        return ($value != '');
309
    }
310
311
    /**
312
     * Accessor for the filter attached to a named field.
313
     *
314
     * @param string $name
315
     * @return SearchFilter
316
     */
317
    public function getFilter($name)
318
    {
319
        if (isset($this->filters[$name])) {
320
            return $this->filters[$name];
321
        } else {
322
            return null;
323
        }
324
    }
325
326
    /**
327
     * Get the map of filters in the current search context.
328
     *
329
     * @return SearchFilter[]
330
     */
331
    public function getFilters()
332
    {
333
        return $this->filters;
334
    }
335
336
    /**
337
     * Overwrite the current search context filter map.
338
     *
339
     * @param array $filters
340
     */
341
    public function setFilters($filters)
342
    {
343
        $this->filters = $filters;
344
    }
345
346
    /**
347
     * Adds a instance of {@link SearchFilter}.
348
     *
349
     * @param SearchFilter $filter
350
     */
351
    public function addFilter($filter)
352
    {
353
        $this->filters[$filter->getFullName()] = $filter;
354
    }
355
356
    /**
357
     * Removes a filter by name.
358
     *
359
     * @param string $name
360
     */
361
    public function removeFilterByName($name)
362
    {
363
        unset($this->filters[$name]);
364
    }
365
366
    /**
367
     * Get the list of searchable fields in the current search context.
368
     *
369
     * @return FieldList
370
     */
371
    public function getFields()
372
    {
373
        return $this->fields;
374
    }
375
376
    /**
377
     * Apply a list of searchable fields to the current search context.
378
     *
379
     * @param FieldList $fields
380
     */
381
    public function setFields($fields)
382
    {
383
        $this->fields = $fields;
384
    }
385
386
    /**
387
     * Adds a new {@link FormField} instance.
388
     *
389
     * @param FormField $field
390
     */
391
    public function addField($field)
392
    {
393
        $this->fields->push($field);
394
    }
395
396
    /**
397
     * Removes an existing formfield instance by its name.
398
     *
399
     * @param string $fieldName
400
     */
401
    public function removeFieldByName($fieldName)
402
    {
403
        $this->fields->removeByName($fieldName);
404
    }
405
406
    /**
407
     * Set search param values
408
     *
409
     * @param array|HTTPRequest $searchParams
410
     * @return $this
411
     */
412
    public function setSearchParams($searchParams)
413
    {
414
        // hack to work with $searchParams when it's an Object
415
        if ($searchParams instanceof HTTPRequest) {
416
            $this->searchParams = $searchParams->getVars();
417
        } else {
418
            $this->searchParams = $searchParams;
419
        }
420
        return $this;
421
    }
422
423
    /**
424
     * @return array
425
     */
426
    public function getSearchParams()
427
    {
428
        return $this->searchParams;
429
    }
430
431
    /**
432
     * Gets a list of what fields were searched and the values provided
433
     * for each field. Returns an ArrayList of ArrayData, suitable for
434
     * rendering on a template.
435
     *
436
     * @return ArrayList
437
     */
438
    public function getSummary()
439
    {
440
        $list = ArrayList::create();
441
        foreach ($this->searchParams as $searchField => $searchValue) {
442
            if (empty($searchValue)) {
443
                continue;
444
            }
445
            $filter = $this->getFilter($searchField);
446
            if (!$filter) {
447
                continue;
448
            }
449
450
            $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...
451
            if (!$field) {
452
                continue;
453
            }
454
455
            // For dropdowns, checkboxes, etc, get the value that was presented to the user
456
            // e.g. not an ID
457
            if ($field instanceof SelectField) {
458
                $source = $field->getSource();
459
                if (isset($source[$searchValue])) {
460
                    $searchValue = $source[$searchValue];
461
                }
462
            } else {
463
                // For checkboxes, it suffices to simply include the field in the list, since it's binary
464
                if ($field instanceof CheckboxField) {
465
                    $searchValue = null;
466
                }
467
            }
468
469
            $list->push(ArrayData::create([
470
                'Field' => $field->Title(),
471
                'Value' => $searchValue,
472
            ]));
473
        }
474
475
        return $list;
476
    }
477
}
478