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