Passed
Pull Request — 4 (#10199)
by
unknown
06:39 queued 13s
created

SearchContext::addField()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
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
21
/**
22
 * Manages searching of properties on one or more {@link DataObject}
23
 * types, based on a given set of input parameters.
24
 * SearchContext is intentionally decoupled from any controller-logic,
25
 * it just receives a set of search parameters and an object class it acts on.
26
 *
27
 * The default output of a SearchContext is either a {@link SQLSelect} object
28
 * for further refinement, or a {@link SS_List} that can be used to display
29
 * search results, e.g. in a {@link TableListField} instance.
30
 *
31
 * In case you need multiple contexts, consider namespacing your request parameters
32
 * by using {@link FieldList->namespace()} on the $fields constructor parameter.
33
 *
34
 * Each DataObject subclass can have multiple search contexts for different cases,
35
 * e.g. for a limited frontend search and a fully featured backend search.
36
 * By default, you can use {@link DataObject->getDefaultSearchContext()} which is automatically
37
 * scaffolded. It uses {@link DataObject::$searchable_fields} to determine which fields
38
 * to include.
39
 *
40
 * @see http://doc.silverstripe.com/doku.php?id=searchcontext
41
 */
42
class SearchContext
43
{
44
    use Injectable;
45
46
    /**
47
     * DataObject subclass to which search parameters relate to.
48
     * Also determines as which object each result is provided.
49
     *
50
     * @var string
51
     */
52
    protected $modelClass;
53
54
    /**
55
     * FormFields mapping to {@link DataObject::$db} properties
56
     * which are supposed to be searchable.
57
     *
58
     * @var FieldList
59
     */
60
    protected $fields;
61
62
    /**
63
     * Array of {@link SearchFilter} subclasses.
64
     *
65
     * @var SearchFilter[]
66
     */
67
    protected $filters;
68
69
    /**
70
     * Key/value pairs of search fields to search terms
71
     *
72
     * @var array
73
     */
74
    protected $searchParams = [];
75
76
    /**
77
     * The logical connective used to join WHERE clauses. Defaults to AND.
78
     * @var string
79
     */
80
    public $connective = 'AND';
81
82
    /**
83
     * A key value pair of values that should be searched for.
84
     * The keys should match the field names specified in {@link self::$fields}.
85
     * Usually these values come from a submitted searchform
86
     * in the form of a $_REQUEST object.
87
     * CAUTION: All values should be treated as insecure client input.
88
     *
89
     * @param string $modelClass The base {@link DataObject} class that search properties related to.
90
     *                      Also used to generate a set of result objects based on this class.
91
     * @param FieldList $fields Optional. FormFields mapping to {@link DataObject::$db} properties
92
     *                      which are to be searched. Derived from modelclass using
93
     *                      {@link DataObject::scaffoldSearchFields()} if left blank.
94
     * @param array $filters Optional. Derived from modelclass if left blank
95
     */
96
    public function __construct($modelClass, $fields = null, $filters = null)
97
    {
98
        $this->modelClass = $modelClass;
99
        $this->fields = ($fields) ? $fields : new FieldList();
100
        $this->filters = ($filters) ? $filters : [];
101
    }
102
103
    /**
104
     * Returns scaffolded search fields for UI.
105
     *
106
     * @return FieldList
107
     */
108
    public function getSearchFields()
109
    {
110
        return ($this->fields) ? $this->fields : singleton($this->modelClass)->scaffoldSearchFields();
111
        // $this->fields is causing weirdness, so we ignore for now, using the default scaffolding
112
        //return 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
        /** DataList $query */
150
        $query = null;
151
        if ($existingQuery) {
152
            if (!($existingQuery instanceof DataList)) {
0 ignored issues
show
introduced by
$existingQuery is always a sub-type of SilverStripe\ORM\DataList.
Loading history...
153
                throw new InvalidArgumentException("existingQuery must be DataList");
154
            }
155
            if ($existingQuery->dataClass() != $this->modelClass) {
156
                throw new InvalidArgumentException("existingQuery's dataClass is " . $existingQuery->dataClass()
157
                    . ", $this->modelClass expected.");
158
            }
159
            $query = $existingQuery;
160
        } else {
161
            $query = DataList::create($this->modelClass);
162
        }
163
164
        if (is_array($limit)) {
165
            $query = $query->limit(
166
                isset($limit['limit']) ? $limit['limit'] : null,
167
                isset($limit['start']) ? $limit['start'] : null
168
            );
169
        } else {
170
            $query = $query->limit($limit);
171
        }
172
173
        /** @var DataList $query */
174
        $query = $query->sort($sort);
175
        $this->setSearchParams($searchParams);
176
177
        foreach ($this->searchParams as $key => $value) {
178
            $key = str_replace('__', '.', $key);
179
            if ($filter = $this->getFilter($key)) {
180
                $filter->setModel($this->modelClass);
181
                $filter->setValue($value);
182
                if (!$filter->isEmpty()) {
183
                    $modelObj = Injector::inst()->create($this->modelClass);
184
                    /*
185
                        If using a method to search. e.g.
186
187
                        private static $searchable_fields = [
188
                            'getSearchableFirstName' => [
189
                                'title' => 'First Name',
190
                                'filter' => 'PartialMatchFilter',
191
                                'field' => TextField::class,
192
                            ]
193
                        ]
194
195
                        public function getSearchableFirstName($queryName = '')
196
                        {
197
                            return [
198
                                'Customer.FirstName' => $queryName,
199
                                'ShippingAddress.FirstName' => $queryName,
200
                            ];
201
                        }
202
                    */
203
                    if($modelObj->hasMethod($key)){
204
                        $query = $query->alterDataQuery(function ($dataQuery) use ($modelObj, $key, $value) {
205
                            $searchFields = $modelObj->$key($value);
206
                            $sqlSearchFields = [];
207
                            foreach(array_keys($searchFields) as $dottedRelation){
208
                                $relation = substr($dottedRelation, 0, strpos($dottedRelation, '.'));
209
                                $relations = explode('.', $dottedRelation);
210
                                $fieldName = array_pop($relations);
211
212
                                // Apply join
213
                                $relationModelName = $dataQuery->applyRelation($relation);
214
215
                                // Get prefixed column
216
                                $relationPrefix = $dataQuery->applyRelationPrefix($relation);
217
218
                                // Find the db field the relation belongs to
219
                                $columnName = $modelObj->getSchema()
220
                                    ->sqlColumnForField($relationModelName, $fieldName, $relationPrefix);
221
222
                                // Update filters to used the sqlColumnForField
223
                                $sqlSearchFields[$columnName] = $value;
224
                            }
225
                            $dataQuery = $dataQuery->whereAny($sqlSearchFields);
0 ignored issues
show
Unused Code introduced by
The assignment to $dataQuery is dead and can be removed.
Loading history...
226
                        });
227
                    } else {
228
                        $query = $query->alterDataQuery([$filter, 'apply']);
229
                    }
230
                }
231
            }
232
        }
233
234
        if ($this->connective != "AND") {
235
            throw new Exception("SearchContext connective '$this->connective' not supported after ORM-rewrite.");
236
        }
237
238
        return $query;
239
    }
240
241
    /**
242
     * Returns a result set from the given search parameters.
243
     *
244
     * @todo rearrange start and limit params to reflect DataObject
245
     *
246
     * @param array $searchParams
247
     * @param array|bool|string $sort
248
     * @param array|bool|string $limit
249
     * @return DataList
250
     * @throws Exception
251
     */
252
    public function getResults($searchParams, $sort = false, $limit = false)
253
    {
254
        $searchParams = array_filter((array)$searchParams, [$this, 'clearEmptySearchFields']);
255
256
        // getQuery actually returns a DataList
257
        return $this->getQuery($searchParams, $sort, $limit);
258
    }
259
260
    /**
261
     * Callback map function to filter fields with empty values from
262
     * being included in the search expression.
263
     *
264
     * @param mixed $value
265
     * @return boolean
266
     */
267
    public function clearEmptySearchFields($value)
268
    {
269
        return ($value != '');
270
    }
271
272
    /**
273
     * Accessor for the filter attached to a named field.
274
     *
275
     * @param string $name
276
     * @return SearchFilter
277
     */
278
    public function getFilter($name)
279
    {
280
        if (isset($this->filters[$name])) {
281
            return $this->filters[$name];
282
        } else {
283
            return null;
284
        }
285
    }
286
287
    /**
288
     * Get the map of filters in the current search context.
289
     *
290
     * @return SearchFilter[]
291
     */
292
    public function getFilters()
293
    {
294
        return $this->filters;
295
    }
296
297
    /**
298
     * Overwrite the current search context filter map.
299
     *
300
     * @param array $filters
301
     */
302
    public function setFilters($filters)
303
    {
304
        $this->filters = $filters;
305
    }
306
307
    /**
308
     * Adds a instance of {@link SearchFilter}.
309
     *
310
     * @param SearchFilter $filter
311
     */
312
    public function addFilter($filter)
313
    {
314
        $this->filters[$filter->getFullName()] = $filter;
315
    }
316
317
    /**
318
     * Removes a filter by name.
319
     *
320
     * @param string $name
321
     */
322
    public function removeFilterByName($name)
323
    {
324
        unset($this->filters[$name]);
325
    }
326
327
    /**
328
     * Get the list of searchable fields in the current search context.
329
     *
330
     * @return FieldList
331
     */
332
    public function getFields()
333
    {
334
        return $this->fields;
335
    }
336
337
    /**
338
     * Apply a list of searchable fields to the current search context.
339
     *
340
     * @param FieldList $fields
341
     */
342
    public function setFields($fields)
343
    {
344
        $this->fields = $fields;
345
    }
346
347
    /**
348
     * Adds a new {@link FormField} instance.
349
     *
350
     * @param FormField $field
351
     */
352
    public function addField($field)
353
    {
354
        $this->fields->push($field);
355
    }
356
357
    /**
358
     * Removes an existing formfield instance by its name.
359
     *
360
     * @param string $fieldName
361
     */
362
    public function removeFieldByName($fieldName)
363
    {
364
        $this->fields->removeByName($fieldName);
365
    }
366
367
    /**
368
     * Set search param values
369
     *
370
     * @param array|HTTPRequest $searchParams
371
     * @return $this
372
     */
373
    public function setSearchParams($searchParams)
374
    {
375
        // hack to work with $searchParams when it's an Object
376
        if ($searchParams instanceof HTTPRequest) {
377
            $this->searchParams = $searchParams->getVars();
378
        } else {
379
            $this->searchParams = $searchParams;
380
        }
381
        return $this;
382
    }
383
384
    /**
385
     * @return array
386
     */
387
    public function getSearchParams()
388
    {
389
        return $this->searchParams;
390
    }
391
392
    /**
393
     * Gets a list of what fields were searched and the values provided
394
     * for each field. Returns an ArrayList of ArrayData, suitable for
395
     * rendering on a template.
396
     *
397
     * @return ArrayList
398
     */
399
    public function getSummary()
400
    {
401
        $list = ArrayList::create();
402
        foreach ($this->searchParams as $searchField => $searchValue) {
403
            if (empty($searchValue)) {
404
                continue;
405
            }
406
            $filter = $this->getFilter($searchField);
407
            if (!$filter) {
408
                continue;
409
            }
410
411
            $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...
412
            if (!$field) {
413
                continue;
414
            }
415
416
            // For dropdowns, checkboxes, etc, get the value that was presented to the user
417
            // e.g. not an ID
418
            if ($field instanceof SelectField) {
419
                $source = $field->getSource();
420
                if (isset($source[$searchValue])) {
421
                    $searchValue = $source[$searchValue];
422
                }
423
            } else {
424
                // For checkboxes, it suffices to simply include the field in the list, since it's binary
425
                if ($field instanceof CheckboxField) {
426
                    $searchValue = null;
427
                }
428
            }
429
430
            $list->push(ArrayData::create([
431
                'Field' => $field->Title(),
432
                'Value' => $searchValue,
433
            ]));
434
        }
435
436
        return $list;
437
    }
438
}
439