Passed
Push — master ( 7a98d0...9dd459 )
by Guy
01:56
created

checkValueMatchesSearchFilter()   D

Complexity

Conditions 17
Paths 180

Size

Total Lines 83
Code Lines 64

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 2 Features 0
Metric Value
cc 17
eloc 64
c 4
b 2
f 0
nc 180
nop 2
dl 0
loc 83
rs 4.55

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Signify\SearchFilterArrayList;
4
5
use LogicException;
6
use SilverStripe\Core\Injector\Injector;
7
use SilverStripe\ORM\ArrayList;
8
use SilverStripe\ORM\Filters\EndsWithFilter;
9
use SilverStripe\ORM\Filters\ExactMatchFilter;
10
use SilverStripe\ORM\Filters\GreaterThanFilter;
11
use SilverStripe\ORM\Filters\GreaterThanOrEqualFilter;
12
use SilverStripe\ORM\Filters\LessThanFilter;
13
use SilverStripe\ORM\Filters\LessThanOrEqualFilter;
14
use SilverStripe\ORM\Filters\PartialMatchFilter;
15
use SilverStripe\ORM\Filters\SearchFilter;
16
use SilverStripe\ORM\Filters\StartsWithFilter;
17
18
class SearchFilterableArrayList extends ArrayList
19
{
20
    /**
21
     * Find the first item of this list where the given key = value
22
     * Note that search filters can also be used, but dot notation is not respected.
23
     *
24
     * @inheritdoc
25
     * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/
26
     */
27
    public function find($key, $value)
28
    {
29
        return $this->filter($key, $value)->first();
30
    }
31
32
    /**
33
     * Filter the list to include items with these charactaristics.
34
     * Note that search filters can also be used, but dot notation is not respected.
35
     *
36
     * @inheritdoc
37
     * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/
38
     */
39
    public function filter()
40
    {
41
        $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args());
42
        return $this->filterOrExclude($filters);
43
    }
44
45
    /**
46
     * Return a copy of this list which contains items matching any of these charactaristics.
47
     * Note that search filters can also be used, but dot notation is not respected.
48
     *
49
     * @inheritdoc
50
     * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/
51
     */
52
    public function filterAny()
53
    {
54
        $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args());
55
        return $this->filterOrExclude($filters, true, true);
56
    }
57
58
    /**
59
     * Exclude the list to not contain items with these charactaristics
60
     * Note that search filters can also be used, but dot notation is not respected.
61
     *
62
     * @inheritdoc
63
     * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/
64
     */
65
    public function exclude()
66
    {
67
        $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args());
68
        return $this->filterOrExclude($filters, false);
69
    }
70
71
    /**
72
     * Exclude the list to not contain items matching any of these charactaristics
73
     * Note that search filters can also be used, but dot notation is not respected.
74
     *
75
     * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/
76
     */
77
    public function excludeAny()
78
    {
79
        $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args());
80
        return $this->filterOrExclude($filters, false, true);
81
    }
82
83
    /**
84
     * Apply the appropriate filtering or excluding
85
     *
86
     * @param array $filters
87
     * @return static
88
     */
89
    protected function filterOrExclude($filters, $inclusive = true, $any = false)
90
    {
91
        $remainingItems = [];
92
        $searchFilters = [];
93
        foreach ($this->items as $item) {
94
            $matches = [];
95
            foreach ($filters as $filterKey => $filterValue) {
96
                if (array_key_exists($filterKey, $searchFilters)) {
97
                    $searchFilter = $searchFilters[$filterKey];
98
                } else {
99
                    $searchFilter = $this->createSearchFilter($filterKey, $filterValue);
100
                    $searchFilters[$filterKey] = $searchFilter;
101
                }
102
                $hasMatch = $this->checkValueMatchesSearchFilter($searchFilter, $item);
103
                $matches[] = $hasMatch;
104
                // If this is excludeAny or filterAny and we have a match, we can stop looking for matches.
105
                if ($any && $hasMatch) {
106
                    break;
107
                }
108
            }
109
            // filterAny or excludeAny allow any true value to be a match - otherwise any false value denotes a mismatch.
110
            $isMatch = $any ? in_array(true, $matches) : !in_array(false, $matches);
111
            // If inclusive (filter) and we have a match, or exclusive (exclude) and there is NO match, keep the item.
112
            if (($inclusive && $isMatch) || (!$inclusive && !$isMatch)) {
113
                $remainingItems[] = $item;
114
            }
115
        }
116
        return static::create($remainingItems);
117
    }
118
119
    /**
120
     * Determine if an item is matched by a given SearchFilter.
121
     *
122
     * Regex with explicitly casted strings is used for many of these checks, which allows for things like
123
     * '1' to match true, without 'abcd' matching true. This can be useful for things like checkboxes which
124
     * will often return '1' or '0', but we don't want 'abcd' to match against the truthy '1', nor a raw true value.
125
     *
126
     * Dot notation is not respected (if you try to filter against "Field.Count", it will be searching for a field or array
127
     * key literally called "Field.Count". This is consistent with the behaviour of ArrayList).
128
     *
129
     * TODO: Consider respecting dot notation in the future.
130
     *
131
     * @param SearchFilter $searchFilter
132
     * @param mixed $item
133
     * @return bool
134
     */
135
    protected function checkValueMatchesSearchFilter(SearchFilter $searchFilter, $item): bool
136
    {
137
        $modifiers = $searchFilter->getModifiers();
138
        $caseSensitive = !in_array('nocase', $modifiers);
139
        $regexSensitivity = $caseSensitive ? '' : 'i';
140
        $negated = in_array('not', $modifiers);
141
        $field = $searchFilter->getFullName();
142
        $extractedValue = $this->extractValue($item, $field);
143
        $extractedValueString = (string)$extractedValue;
144
        $values = $searchFilter->getValue();
145
        if (!is_array($values)) {
146
            $values = [$values];
147
        }
148
        $fieldMatches = false;
149
        foreach ($values as $value) {
150
            $unsupported = false;
151
            $value = (string)$value;
152
            $regexSafeValue = preg_quote($value, '/');
153
            switch (get_class($searchFilter)) {
154
                case EndsWithFilter::class:
155
                    if (is_bool($extractedValue)) {
156
                        $doesMatch = false;
157
                    } else {
158
                        $doesMatch = preg_match(
159
                            '/' . $regexSafeValue . '$/' . $regexSensitivity,
160
                            $extractedValueString
161
                        );
162
                    }
163
                    break;
164
                case ExactMatchFilter::class:
165
                    $doesMatch = preg_match(
166
                        '/^' . $regexSafeValue . '$/' . $regexSensitivity,
167
                        $extractedValueString
168
                    );
169
                    break;
170
                case GreaterThanFilter::class:
171
                    $doesMatch = $extractedValueString > $value;
172
                    break;
173
                case GreaterThanOrEqualFilter::class:
174
                    $doesMatch = $extractedValueString >= $value;
175
                    break;
176
                case LessThanFilter::class:
177
                    $doesMatch = $extractedValueString < $value;
178
                    break;
179
                case LessThanOrEqualFilter::class:
180
                    $doesMatch = $extractedValueString <= $value;
181
                    break;
182
                case PartialMatchFilter::class:
183
                    $doesMatch = preg_match(
184
                        '/' . $regexSafeValue . '/' . $regexSensitivity,
185
                        $extractedValueString
186
                    );
187
                    break;
188
                case StartsWithFilter::class:
189
                    if (is_bool($extractedValue)) {
190
                        $doesMatch = false;
191
                    } else {
192
                        $doesMatch = preg_match(
193
                            '/^' . $regexSafeValue . '/' . $regexSensitivity,
194
                            $extractedValueString
195
                        );
196
                    }
197
                    break;
198
                default:
199
                    // This will only be reached if an Extension class added classes to getSupportedSearchFilterClasses().
200
                    $doesMatch = false;
201
                    $unsupported = true;
202
            }
203
204
            // Respect "not" modifier.
205
            if ($negated) {
206
                $doesMatch = !$doesMatch;
207
            }
208
            // If any value matches, then we consider the field to have matched.
209
            if (!$unsupported && $doesMatch) {
210
                $fieldMatches = true;
211
                break;
212
            }
213
        }
214
215
        // Allow developers to make their own changes (e.g. for unsupported SearchFilters or modifiers).
216
        $this->extend('updateFilterMatch', $fieldMatches, $extractedValue, $searchFilter);
217
        return $fieldMatches;
218
    }
219
220
    /**
221
     * Given a filter expression and value construct a {@see SearchFilter} instance
222
     *
223
     * @param string $filter E.g. `Name:ExactMatch:not:nocase`, `Name:ExactMatch`, `Name:not`, `Name`, etc...
224
     * @param mixed $value Value of the filter
225
     * @return SearchFilter
226
     * @see \SilverStripe\ORM\DataList::createSearchFilter
227
     */
228
    protected function createSearchFilter(string $filter, $value)
229
    {
230
        // Field name is always the first component
231
        $fieldArgs = explode(':', $filter);
232
        $fieldName = array_shift($fieldArgs);
233
        $default = 'DataListFilter.default';
234
235
        // Inspect type of second argument to determine context
236
        $secondArg = array_shift($fieldArgs);
237
        $modifiers = $fieldArgs;
238
        if (!$secondArg) {
239
            // Use default SearchFilter if none specified. E.g. `->filter(['Name' => $myname])`
240
            $filterServiceName = $default;
241
        } else {
242
            // The presence of a second argument is by default ambiguous; We need to query
243
            // Whether this is a valid modifier on the default filter, or a filter itself.
244
            /** @var SearchFilter $defaultFilterInstance */
245
            $defaultFilterInstance = Injector::inst()->get($default);
246
            if (in_array(strtolower($secondArg), $defaultFilterInstance->getSupportedModifiers())) {
247
                // Treat second (and any subsequent) argument as modifiers, using default filter
248
                $filterServiceName = $default;
249
                array_unshift($modifiers, $secondArg);
250
            } else {
251
                // Second argument isn't a valid modifier, so assume is filter identifier
252
                $filterServiceName = "DataListFilter.{$secondArg}";
253
            }
254
        }
255
        // Explicitly don't allow unsupported modifiers instead of silently ignoring them.
256
        if (!empty($invalid = array_diff($modifiers, $this->getSupportedModifiers()))) {
257
            throw new LogicException('Unsupported SearchFilter modifier(s): ' . implode(', ', $invalid));
258
        }
259
260
        // Build instance
261
        $filter = Injector::inst()->create($filterServiceName, $fieldName, $value, $modifiers);
262
        // Explicitly don't allow unsupported SearchFilters instead of silently ignoring them.
263
        if (!in_array(get_class($filter), $this->getSupportedSearchFilterClasses())) {
264
            throw new LogicException('Unsupported SearchFilter class: ' . get_class($filter));
265
        }
266
267
        return $filter;
268
    }
269
270
    /**
271
     * Get the SearchFilter classes supported by this class.
272
     *
273
     * @return string[]
274
     */
275
    protected function getSupportedSearchFilterClasses(): array
276
    {
277
        $supportedClasses = [
278
            EndsWithFilter::class,
279
            ExactMatchFilter::class,
280
            GreaterThanFilter::class,
281
            GreaterThanOrEqualFilter::class,
282
            LessThanFilter::class,
283
            LessThanOrEqualFilter::class,
284
            PartialMatchFilter::class,
285
            StartsWithFilter::class,
286
        ];
287
        // Allow developers to add their own SearchFilter classes.
288
        $this->extend('updateSupportedSearchFilterClasses', $supportedClasses);
289
        return $supportedClasses;
290
    }
291
292
    /**
293
     * Get the SearchFilter modifiers supported by this class.
294
     *
295
     * @return string[]
296
     */
297
    protected function getSupportedModifiers(): array
298
    {
299
        $supportedModifiers = ['not', 'nocase', 'case'];
300
        // Allow developers to add their own modifiers.
301
        $this->extend('updateSupportedModifiers', $supportedModifiers);
302
        return $supportedModifiers;
303
    }
304
}
305