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