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

SearchContext::prepareQuery()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 26
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 17
nc 6
nop 3
dl 0
loc 26
rs 8.8333
c 0
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
        $filter = $this->getFilter($searchField);
286
        if (!$filter) {
0 ignored issues
show
introduced by
$filter is of type SilverStripe\ORM\Filters\SearchFilter, thus it always evaluated to true.
Loading history...
287
            return $query;
288
        }
289
        $filter->setModel($this->modelClass);
290
        $filter->setValue($searchPhrase);
291
        $searchableFieldSpec = $searchableFields[$searchField] ?? [];
292
        return $query->alterDataQuery(function ($dataQuery) use ($filter, $searchableFieldSpec) {
293
            $this->applyFilter($filter, $dataQuery, $searchableFieldSpec);
294
        });
295
    }
296
297
    /**
298
     * Apply a SearchFilter to a DataQuery for a given field's specifications
299
     */
300
    public function applyFilter(SearchFilter $filter, DataQuery $dataQuery, array $searchableFieldSpec): void
301
    {
302
        if ($filter->isEmpty()) {
303
            return;
304
        }
305
        if (isset($searchableFieldSpec['match_any'])) {
306
            $searchFields = $searchableFieldSpec['match_any'];
307
            $filterClass = get_class($filter);
308
            $value = $filter->getValue();
309
            $modifiers = $filter->getModifiers();
310
            $subGroup = $dataQuery->disjunctiveGroup();
311
            foreach ($searchFields as $matchField) {
312
                /** @var SearchFilter $filter */
313
                $filter = Injector::inst()->create($filterClass, $matchField, $value, $modifiers);
314
                $filter->apply($subGroup);
315
            }
316
        } else {
317
            $filter->apply($dataQuery);
318
        }
319
    }
320
321
    /**
322
     * Returns a result set from the given search parameters.
323
     *
324
     * @todo rearrange start and limit params to reflect DataObject
325
     *
326
     * @param array $searchParams
327
     * @param array|bool|string $sort
328
     * @param array|bool|string $limit
329
     * @return DataList
330
     * @throws Exception
331
     */
332
    public function getResults($searchParams, $sort = false, $limit = false)
333
    {
334
        $searchParams = array_filter((array)$searchParams, [$this, 'clearEmptySearchFields']);
335
336
        // getQuery actually returns a DataList
337
        return $this->getQuery($searchParams, $sort, $limit);
338
    }
339
340
    /**
341
     * Callback map function to filter fields with empty values from
342
     * being included in the search expression.
343
     *
344
     * @param mixed $value
345
     * @return boolean
346
     */
347
    public function clearEmptySearchFields($value)
348
    {
349
        return ($value != '');
350
    }
351
352
    /**
353
     * Accessor for the filter attached to a named field.
354
     *
355
     * @param string $name
356
     * @return SearchFilter
357
     */
358
    public function getFilter($name)
359
    {
360
        if (isset($this->filters[$name])) {
361
            return $this->filters[$name];
362
        } else {
363
            return null;
364
        }
365
    }
366
367
    /**
368
     * Get the map of filters in the current search context.
369
     *
370
     * @return SearchFilter[]
371
     */
372
    public function getFilters()
373
    {
374
        return $this->filters;
375
    }
376
377
    /**
378
     * Overwrite the current search context filter map.
379
     *
380
     * @param array $filters
381
     */
382
    public function setFilters($filters)
383
    {
384
        $this->filters = $filters;
385
    }
386
387
    /**
388
     * Adds a instance of {@link SearchFilter}.
389
     *
390
     * @param SearchFilter $filter
391
     */
392
    public function addFilter($filter)
393
    {
394
        $this->filters[$filter->getFullName()] = $filter;
395
    }
396
397
    /**
398
     * Removes a filter by name.
399
     *
400
     * @param string $name
401
     */
402
    public function removeFilterByName($name)
403
    {
404
        unset($this->filters[$name]);
405
    }
406
407
    /**
408
     * Get the list of searchable fields in the current search context.
409
     *
410
     * @return FieldList
411
     */
412
    public function getFields()
413
    {
414
        return $this->fields;
415
    }
416
417
    /**
418
     * Apply a list of searchable fields to the current search context.
419
     *
420
     * @param FieldList $fields
421
     */
422
    public function setFields($fields)
423
    {
424
        $this->fields = $fields;
425
    }
426
427
    /**
428
     * Adds a new {@link FormField} instance.
429
     *
430
     * @param FormField $field
431
     */
432
    public function addField($field)
433
    {
434
        $this->fields->push($field);
435
    }
436
437
    /**
438
     * Removes an existing formfield instance by its name.
439
     *
440
     * @param string $fieldName
441
     */
442
    public function removeFieldByName($fieldName)
443
    {
444
        $this->fields->removeByName($fieldName);
445
    }
446
447
    /**
448
     * Set search param values
449
     *
450
     * @param array|HTTPRequest $searchParams
451
     * @return $this
452
     */
453
    public function setSearchParams($searchParams)
454
    {
455
        // hack to work with $searchParams when it's an Object
456
        if ($searchParams instanceof HTTPRequest) {
457
            $this->searchParams = $searchParams->getVars();
458
        } else {
459
            $this->searchParams = $searchParams;
460
        }
461
        return $this;
462
    }
463
464
    /**
465
     * @return array
466
     */
467
    public function getSearchParams()
468
    {
469
        return $this->searchParams;
470
    }
471
472
    /**
473
     * Gets a list of what fields were searched and the values provided
474
     * for each field. Returns an ArrayList of ArrayData, suitable for
475
     * rendering on a template.
476
     *
477
     * @return ArrayList
478
     */
479
    public function getSummary()
480
    {
481
        $list = ArrayList::create();
482
        foreach ($this->searchParams as $searchField => $searchValue) {
483
            if (empty($searchValue)) {
484
                continue;
485
            }
486
            $filter = $this->getFilter($searchField);
487
            if (!$filter) {
488
                continue;
489
            }
490
491
            $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...
492
            if (!$field) {
493
                continue;
494
            }
495
496
            // For dropdowns, checkboxes, etc, get the value that was presented to the user
497
            // e.g. not an ID
498
            if ($field instanceof SelectField) {
499
                $source = $field->getSource();
500
                if (isset($source[$searchValue])) {
501
                    $searchValue = $source[$searchValue];
502
                }
503
            } else {
504
                // For checkboxes, it suffices to simply include the field in the list, since it's binary
505
                if ($field instanceof CheckboxField) {
506
                    $searchValue = null;
507
                }
508
            }
509
510
            $list->push(ArrayData::create([
511
                'Field' => $field->Title(),
512
                'Value' => $searchValue,
513
            ]));
514
        }
515
516
        return $list;
517
    }
518
}
519