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

150
        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...
151
            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

151
            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...
152
        }
153
        $this->setSearchParams($searchParams);
154
        $query = $this->prepareQuery($sort, $limit, $existingQuery);
155
        return $this->search($query);
156
    }
157
158
    /**
159
     * Perform a search on the passed DataList based on $this->searchParams.
160
     */
161
    private function search(DataList $query): DataList
162
    {
163
        /** @var DataObject $modelObj */
164
        $modelObj = Injector::inst()->create($this->modelClass);
165
        $searchableFields = $modelObj->searchableFields();
166
        foreach ($this->searchParams as $searchField => $searchPhrase) {
167
            $searchField = str_replace('__', '.', $searchField ?? '');
168
            if ($searchField !== '' && $searchField === $modelObj->primarySearchFieldName()) {
169
                $query = $this->primaryFieldSearch($query, $searchableFields, $searchPhrase);
170
            } else {
171
                $query = $this->individualFieldSearch($query, $searchableFields, $searchField, $searchPhrase);
172
            }
173
        }
174
        return $query;
175
    }
176
177
    /**
178
     * Prepare the query to begin searching
179
     *
180
     * @param array|bool|string $sort Database column to sort on.
181
     * @param array|bool|string $limit
182
     */
183
    private function prepareQuery($sort, $limit, ?DataList $existingQuery): DataList
184
    {
185
        $query = null;
186
        if ($existingQuery) {
187
            if (!($existingQuery instanceof DataList)) {
0 ignored issues
show
introduced by
$existingQuery is always a sub-type of SilverStripe\ORM\DataList.
Loading history...
188
                throw new InvalidArgumentException("existingQuery must be DataList");
189
            }
190
            if ($existingQuery->dataClass() != $this->modelClass) {
191
                throw new InvalidArgumentException("existingQuery's dataClass is " . $existingQuery->dataClass()
192
                    . ", $this->modelClass expected.");
193
            }
194
            $query = $existingQuery;
195
        } else {
196
            $query = DataList::create($this->modelClass);
197
        }
198
199
        if (is_array($limit)) {
200
            $query = $query->limit(
201
                isset($limit['limit']) ? $limit['limit'] : null,
202
                isset($limit['start']) ? $limit['start'] : null
203
            );
204
        } else {
205
            $query = $query->limit($limit);
206
        }
207
208
        return $query->sort($sort);
209
    }
210
211
    /**
212
     * Takes a search phrase or search term and searches for it across all searchable fields.
213
     *
214
     * @param string|array $searchPhrase
215
     */
216
    private function searchAcrossFields($searchPhrase, DataQuery $subGroup, array $searchableFields): void
217
    {
218
        $formFields = $this->getSearchFields();
219
        foreach ($searchableFields as $field => $spec) {
220
            $formFieldName = str_replace('.', '__', $field);
221
            $filter = $this->getPrimarySearchFilter($this->modelClass, $field);
222
            // Only apply filter if the field is allowed to be primary and is backed by a form field.
223
            // Otherwise we could be dealing with, for example, a DataObject which implements scaffoldSearchField
224
            // to provide some unexpected field name, where the below would result in a DatabaseException.
225
            if ((!isset($spec['primary']) || $spec['primary'])
226
                && ($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...
227
                && $filter !== null
228
            ) {
229
                $filter->setModel($this->modelClass);
230
                $filter->setValue($searchPhrase);
231
                $this->applyFilter($filter, $subGroup, $spec);
232
            }
233
        }
234
    }
235
236
    /**
237
     * Use the global primary search for searching across multiple fields
238
     *
239
     * @param string|array $searchPhrase
240
     */
241
    private function primaryFieldSearch(DataList $query, array $searchableFields, $searchPhrase): DataList
242
    {
243
        return $query->alterDataQuery(function (DataQuery $dataQuery) use ($searchableFields, $searchPhrase) {
244
            // If necessary, split search phrase into terms, then search across fields.
245
            if (Config::inst()->get($this->modelClass, 'primary_search_split_terms')) {
246
                if (is_array($searchPhrase)) {
247
                    // Allow matches from ANY query in the array (i.e. return $obj where query1 matches OR query2 matches)
248
                    $dataQuery = $dataQuery->disjunctiveGroup();
249
                    foreach ($searchPhrase as $phrase) {
250
                        // where ((field1 like lorem OR field2 like lorem) AND (field1 like ipsum OR field2 like ipsum))
251
                        $primarySubGroup = $dataQuery->conjunctiveGroup();
252
                        foreach (explode(' ', $phrase) as $searchTerm) {
253
                            $this->searchAcrossFields($searchTerm, $primarySubGroup->disjunctiveGroup(), $searchableFields);
254
                        }
255
                    }
256
                } else {
257
                    // where ((field1 like lorem OR field2 like lorem) AND (field1 like ipsum OR field2 like ipsum))
258
                    $primarySubGroup = $dataQuery->conjunctiveGroup();
259
                    foreach (explode(' ', $searchPhrase) as $searchTerm) {
260
                        $this->searchAcrossFields($searchTerm, $primarySubGroup->disjunctiveGroup(), $searchableFields);
261
                    }
262
                }
263
            } else {
264
                // where (field1 like lorem ipsum OR field2 like lorem ipsum)
265
                $this->searchAcrossFields($searchPhrase, $dataQuery->disjunctiveGroup(), $searchableFields);
266
            }
267
        });
268
    }
269
270
    /**
271
     * Get the search filter for the given fieldname when searched from the primary search field.
272
     */
273
    private function getPrimarySearchFilter(string $modelClass, string $fieldName): ?SearchFilter
274
    {
275
        if ($filterClass = Config::inst()->get($modelClass, 'primary_search_field_filter')) {
276
            return Injector::inst()->create($filterClass, $fieldName);
277
        }
278
        return $this->getFilter($fieldName);
279
    }
280
281
    /**
282
     * Search against a single field
283
     *
284
     * @param string|array $searchPhrase
285
     */
286
    private function individualFieldSearch(DataList $query, array $searchableFields, string $searchField, $searchPhrase): DataList
287
    {
288
        $filter = $this->getFilter($searchField);
289
        if (!$filter) {
0 ignored issues
show
introduced by
$filter is of type SilverStripe\ORM\Filters\SearchFilter, thus it always evaluated to true.
Loading history...
290
            return $query;
291
        }
292
        $filter->setModel($this->modelClass);
293
        $filter->setValue($searchPhrase);
294
        $searchableFieldSpec = $searchableFields[$searchField] ?? [];
295
        return $query->alterDataQuery(function ($dataQuery) use ($filter, $searchableFieldSpec) {
296
            $this->applyFilter($filter, $dataQuery, $searchableFieldSpec);
297
        });
298
    }
299
300
    /**
301
     * Apply a SearchFilter to a DataQuery for a given field's specifications
302
     */
303
    private function applyFilter(SearchFilter $filter, DataQuery $dataQuery, array $searchableFieldSpec): void
304
    {
305
        if ($filter->isEmpty()) {
306
            return;
307
        }
308
        if (isset($searchableFieldSpec['match_any'])) {
309
            $searchFields = $searchableFieldSpec['match_any'];
310
            $filterClass = get_class($filter);
311
            $value = $filter->getValue();
312
            $modifiers = $filter->getModifiers();
313
            $subGroup = $dataQuery->disjunctiveGroup();
314
            foreach ($searchFields as $matchField) {
315
                /** @var SearchFilter $filter */
316
                $filter = Injector::inst()->create($filterClass, $matchField, $value, $modifiers);
317
                $filter->apply($subGroup);
318
            }
319
        } else {
320
            $filter->apply($dataQuery);
321
        }
322
    }
323
324
    /**
325
     * Returns a result set from the given search parameters.
326
     *
327
     * @todo rearrange start and limit params to reflect DataObject
328
     *
329
     * @param array $searchParams
330
     * @param array|bool|string $sort
331
     * @param array|bool|string $limit
332
     * @return DataList
333
     * @throws Exception
334
     */
335
    public function getResults($searchParams, $sort = false, $limit = false)
336
    {
337
        $searchParams = array_filter((array)$searchParams, [$this, 'clearEmptySearchFields']);
338
339
        // getQuery actually returns a DataList
340
        return $this->getQuery($searchParams, $sort, $limit);
341
    }
342
343
    /**
344
     * Callback map function to filter fields with empty values from
345
     * being included in the search expression.
346
     *
347
     * @param mixed $value
348
     * @return boolean
349
     */
350
    public function clearEmptySearchFields($value)
351
    {
352
        return ($value != '');
353
    }
354
355
    /**
356
     * Accessor for the filter attached to a named field.
357
     *
358
     * @param string $name
359
     * @return SearchFilter
360
     */
361
    public function getFilter($name)
362
    {
363
        if (isset($this->filters[$name])) {
364
            return $this->filters[$name];
365
        } else {
366
            return null;
367
        }
368
    }
369
370
    /**
371
     * Get the map of filters in the current search context.
372
     *
373
     * @return SearchFilter[]
374
     */
375
    public function getFilters()
376
    {
377
        return $this->filters;
378
    }
379
380
    /**
381
     * Overwrite the current search context filter map.
382
     *
383
     * @param array $filters
384
     */
385
    public function setFilters($filters)
386
    {
387
        $this->filters = $filters;
388
    }
389
390
    /**
391
     * Adds a instance of {@link SearchFilter}.
392
     *
393
     * @param SearchFilter $filter
394
     */
395
    public function addFilter($filter)
396
    {
397
        $this->filters[$filter->getFullName()] = $filter;
398
    }
399
400
    /**
401
     * Removes a filter by name.
402
     *
403
     * @param string $name
404
     */
405
    public function removeFilterByName($name)
406
    {
407
        unset($this->filters[$name]);
408
    }
409
410
    /**
411
     * Get the list of searchable fields in the current search context.
412
     *
413
     * @return FieldList
414
     */
415
    public function getFields()
416
    {
417
        return $this->fields;
418
    }
419
420
    /**
421
     * Apply a list of searchable fields to the current search context.
422
     *
423
     * @param FieldList $fields
424
     */
425
    public function setFields($fields)
426
    {
427
        $this->fields = $fields;
428
    }
429
430
    /**
431
     * Adds a new {@link FormField} instance.
432
     *
433
     * @param FormField $field
434
     */
435
    public function addField($field)
436
    {
437
        $this->fields->push($field);
438
    }
439
440
    /**
441
     * Removes an existing formfield instance by its name.
442
     *
443
     * @param string $fieldName
444
     */
445
    public function removeFieldByName($fieldName)
446
    {
447
        $this->fields->removeByName($fieldName);
448
    }
449
450
    /**
451
     * Set search param values
452
     *
453
     * @param array|HTTPRequest $searchParams
454
     * @return $this
455
     */
456
    public function setSearchParams($searchParams)
457
    {
458
        // hack to work with $searchParams when it's an Object
459
        if ($searchParams instanceof HTTPRequest) {
460
            $this->searchParams = $searchParams->getVars();
461
        } else {
462
            $this->searchParams = $searchParams;
463
        }
464
        return $this;
465
    }
466
467
    /**
468
     * @return array
469
     */
470
    public function getSearchParams()
471
    {
472
        return $this->searchParams;
473
    }
474
475
    /**
476
     * Gets a list of what fields were searched and the values provided
477
     * for each field. Returns an ArrayList of ArrayData, suitable for
478
     * rendering on a template.
479
     *
480
     * @return ArrayList
481
     */
482
    public function getSummary()
483
    {
484
        $list = ArrayList::create();
485
        foreach ($this->searchParams as $searchField => $searchValue) {
486
            if (empty($searchValue)) {
487
                continue;
488
            }
489
            $filter = $this->getFilter($searchField);
490
            if (!$filter) {
491
                continue;
492
            }
493
494
            $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...
495
            if (!$field) {
496
                continue;
497
            }
498
499
            // For dropdowns, checkboxes, etc, get the value that was presented to the user
500
            // e.g. not an ID
501
            if ($field instanceof SelectField) {
502
                $source = $field->getSource();
503
                if (isset($source[$searchValue])) {
504
                    $searchValue = $source[$searchValue];
505
                }
506
            } else {
507
                // For checkboxes, it suffices to simply include the field in the list, since it's binary
508
                if ($field instanceof CheckboxField) {
509
                    $searchValue = null;
510
                }
511
            }
512
513
            $list->push(ArrayData::create([
514
                'Field' => $field->Title(),
515
                'Value' => $searchValue,
516
            ]));
517
        }
518
519
        return $list;
520
    }
521
}
522