Completed
Push — master ( 2f5812...089fcb )
by Denis
03:11
created

QueryFilter   B

Complexity

Total Complexity 41

Size/Duplication

Total Lines 254
Duplicated Lines 0 %

Test Coverage

Coverage 86.6%

Importance

Changes 0
Metric Value
dl 0
loc 254
ccs 84
cts 97
cp 0.866
rs 8.2769
c 0
b 0
f 0
wmc 41

6 Methods

Rating   Name   Duplication   Size   Complexity  
B getSimpleSearchBy() 0 21 6
A getSearchBy() 0 3 2
B getFullSearchBy() 0 20 8
A __construct() 0 20 4
D getData() 0 83 16
B getPageData() 0 22 5

How to fix   Complexity   

Complex Class

Complex classes like QueryFilter often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use QueryFilter, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types = 1);
2
3
namespace Artprima\QueryFilterBundle\QueryFilter;
4
5
use Artprima\QueryFilterBundle\Exception\InvalidArgumentException;
6
use Artprima\QueryFilterBundle\QueryFilter\Config\ConfigInterface;
7
use Artprima\QueryFilterBundle\Response\ResponseInterface;
8
9
/**
10
 * Class QueryFilter
11
 *
12
 * @author Denis Voytyuk <[email protected]>
13
 */
14
class QueryFilter
15
{
16
    /**
17
     * @var string
18
     */
19
    private $responseClassName;
20
21
    /**
22
     * QueryFilter constructor.
23
     * @param string $responseClassName
24
     * @throws \ReflectionException
25
     * @throws InvalidArgumentException
26
     */
27 4
    public function __construct(string $responseClassName)
28
    {
29 4
        $refClass = new \ReflectionClass($responseClassName);
30 4
        if (!$refClass->implementsInterface(ResponseInterface::class)) {
31 1
            throw new InvalidArgumentException(sprintf(
32 1
                'Response class "%s" must implement "%s"',
33 1
                $responseClassName,
34 1
                ResponseInterface::class
35
            ));
36
        }
37
38 3
        $constructor = $refClass->getConstructor();
39 3
        if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) {
40 1
            throw new InvalidArgumentException(sprintf(
41 1
                'Response class "%s" must have a constructor without required parameters',
42 1
                $responseClassName
43
            ));
44
        }
45
46 2
        $this->responseClassName = $responseClassName;
47 2
    }
48
49
    /**
50
     * Prepares data to use for sorting and paging
51
     *
52
     * Resulting 'sortdata' array element contains associated array where keys are column names and values are order
53
     * directions (only one array item is supported now, others are ignored). If no sorting provided, default sorting data is used.
54
     *
55
     * @return array Resulting array contains two elements 'curpage' and 'sortdata' as keys with corresponding values
56
     */
57 2
    private function getPageData(ConfigInterface $config): array
58
    {
59
        $sort = [
60 2
            'field' => $config->getRequest()->getSortBy(),
61 2
            'type' => $config->getRequest()->getSortDir(),
62
        ];
63 2
        $curPage = $config->getRequest()->getPageNum();
64
65 2
        if ($curPage < 1) {
66
            $curPage = 1;
67
        }
68
69 2
        $sortdata = $config->getSortColsDefault();
70 2
        if (isset($sort['field'], $sort['type'])) {
71 2
            if (in_array($sort['type'], array('asc', 'desc'), true) && in_array($sort['field'], $config->getSortCols(), true)) {
72 2
                $sortdata = array($sort['field'] => $sort['type']);
73
            }
74
        }
75
76
        return array(
77 2
            'curpage' => $curPage,
78 2
            'sortdata' => $sortdata,
79
        );
80
    }
81
82 1
    private function getSimpleSearchBy(array $allowedCols, ?array $search): array
83
    {
84 1
        $searchBy = [];
85
86 1
        if ($search === null) {
87
            return $searchBy;
88
        }
89
90 1
        foreach ($search as $key => $val) {
91 1
            if (in_array($key, $allowedCols, true) && $val !== null) {
92 1
                $searchBy[$key] = array(
93 1
                    'type' => 'like',
94 1
                    'val' => $val,
95
                );
96 1
                if (strpos($key, 'GroupConcat') !== false) {
97 1
                    $searchBy[$key]['having'] = true;
98
                }
99
            }
100
        }
101
102 1
        return $searchBy;
103
    }
104
105 1
    private function getFullSearchBy(array $allowedCols, ?array $search): array
106
    {
107 1
        $searchBy = [];
108
109 1
        if ($search === null) {
110
            return $searchBy;
111
        }
112
113 1
        foreach ($search as $data) {
114 1
            if (!empty($data) && is_array($data) && isset($data['field']) && in_array($data['field'], $allowedCols, true)) {
115 1
                $field = $data['field'];
116 1
                unset($data['field']);
117 1
                $searchBy[$field] = $data;
118 1
                if (strpos($field, 'GroupConcat') !== false) {
119 1
                    $searchBy[$field]['having'] = true;
120
                }
121
            }
122
        }
123
124 1
        return $searchBy;
125
    }
126
127
    /**
128
     * Get searchby data prepared for query builder
129
     *
130
     * If simple, $search must be set to:
131
     * <code>
132
     *     $this->searchData = array(
133
     *         'column_name1' => 'search_value1',
134
     *         'column_name2' => 'search_value2',
135
     *     );
136
     * </code>
137
     * All comparisons will be treated as "like" here.
138
     *
139
     * If not simple, $search must be set to:
140
     * <code>
141
     *     $this->searchData = array(
142
     *         array('field' => 'column_name1', 'type' => 'like', 'val' => 'search_value1'),
143
     *         array('field' => 'column_name2', 'type' => 'eq', 'val' => 'search_value2'),
144
     *     );
145
     * </code>
146
     *
147
     * For both cases GroupConcat columns the result will receive extra $searchBy["column_name1"]["having"] = true
148
     *
149
     * @param array $allowedCols
150
     * @param array|null $search
151
     * @param bool $simple
152
     * @return array
153
     */
154 2
    private function getSearchBy(array $allowedCols, ?array $search, bool $simple): array
155
    {
156 2
        return $simple ? $this->getSimpleSearchBy($allowedCols, $search) : $this->getFullSearchBy($allowedCols, $search);
157
    }
158
159
    /**
160
     * Gets data for use in twig templates
161
     *
162
     * Consists of 8 steps:
163
     *
164
     * 1. Builds searchBy (see more: {@link QueryFilter::getSearchBy()}, {@link ConfigInterface::getAllowedCols()},
165
     *   {@link ConfigInterface::getSearchData()}, {@link ConfigInterface::isSimpleSearch()})
166
     *
167
     * 2. Modifies searchBy array with the shortcut expanders (see more: {@link ConfigInterface::getShortcutExpanders()})
168
     *
169
     * 3. Adds searchByExtra (if any) to the initial searchBy (see more: {@link ConfigInterface::getSearchByExtra()})
170
     *
171
     * 4. Obtains paging and sorting data (see more: {@link QueryFilter::getSortPageData()}, {@link ConfigInterface::getSortCols()},
172
     *    {@link ConfigInterface::getCurPage()}, {@link ConfigInterface::getSortBy()},
173
     *    {@link ConfigInterface::getSortColsDefault()})
174
     *
175
     * 5. Obtains data to according to the conditions defined in steps 1-4 (see more:
176
     *    {@link ConfigInterface::getRepositoryCallback()}, {@link ConfigInterface::getItemsPerPage})
177
     *
178
     * 6. Prepares and retuns the response
179
     *
180
     * @todo: refactor to smaller parts
181
     *
182
     * @return ResponseInterface
183
     * @throws InvalidArgumentException
184
     */
185 2
    public function getData(ConfigInterface $config): ResponseInterface
186
    {
187
        // 1. Get  search by from query params limited by specified search by keys
188 2
        $searchBy = $this->getSearchBy(
189 2
            $config->getSearchAllowedCols(),
190 2
            $config->getRequest()->getQuery(),
191 2
            $config->getRequest()->isSimple()
192
        );
193
194
        // (optional) 2. replace search by shortcut(s) with more complicated expressions  // **
195 2
        $aliases = $config->getSearchByAliases();
196 2
        foreach ($aliases as $alias => $value) {
197
            if (!empty($searchBy[$alias])) {
198
                if (!empty($value['data'])) {
199
                    $searchBy[$value['name']] = $value['data'];
200
                    $searchBy[$value['name']]['val'] = $searchBy[$alias]['val'];
201
                } else {
202
                    $searchBy[$value['name']] = $searchBy[$alias];
203
                }
204
                unset($searchBy[$alias]);
205
            }
206
        }
207
208
        // (optional) 3. Set search extra filters (can be used to display entries for one particular entity,
209
        //               or to add some extra conditions/filterings)
210 2
        $searchByExtra = $config->getSearchByExtra();
211 2
        if (!empty($searchByExtra)) {
212
            // @todo: possible further extension
213
            // if (is_object($searchByExtra) && ($searchByExtra instanceof \Closure)) {
0 ignored issues
show
Unused Code Comprehensibility introduced by
58% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
214
            //     $searchByExtra = $searchByExtra($searchBy);
0 ignored issues
show
Unused Code Comprehensibility introduced by
60% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
215
            // }
216
            $searchBy = array_merge($searchBy, $searchByExtra);
217
        }
218
219
        // 4. Obtain paging and sorting data
220 2
        $pageData = $this->getPageData($config);
221
222
        // 4.5 Replace spaces by %
223 2
        foreach ($searchBy as &$item) {
224 2
            if (is_array($item) && isset($item['type'], $item['val']) && ($item['type'] === 'like') && preg_match('/[\s\.,]+/', $item['val'])) {
225 2
                $words = preg_split('/[\s\.,]+/', $item['val']);
226 2
                $item['val'] = $words ? implode('%', $words) : $item['val'];
227
            }
228
        }
229 2
        unset($item);
230
231
        // 5. Query database to obtain corresponding entities
232 2
        $repositoryCallback = $config->getRepositoryCallback();
233 2
        if (!\is_callable($repositoryCallback)) {
234
            throw new InvalidArgumentException('Repository callback is not callable');
235
        }
236 2
        $limit = $config->getRequest()->getLimit();
237 2
        $allowedLimits = $config->getAllowedLimits();
238 2
        if ($limit === -1 || (!empty($allowedLimits) && !in_array($limit, $config->getAllowedLimits(), true))) {
239
            $limit = $config->getDefaultLimit();
240
        }
241 2
        $args = (new QueryFilterArgs())
242 2
            ->setSearchBy($searchBy)
243 2
            ->setSortBy($pageData['sortdata'])
244 2
            ->setLimit($limit)
245 2
            ->setOffset(($pageData['curpage'] - 1) * $limit);
246
247
        // $repositoryCallback can be an array, but since PHP 7.0 it's possible to use it as a function directly
248
        // i.e. without using call_user_func[_array]().
249
        // For the reference: https://trowski.com/2015/06/20/php-callable-paradox/
250 2
        $startTime = microtime(true);
251 2
        $filterData = $repositoryCallback($args);
252 2
        $duration = microtime(true) - $startTime;
253 2
        if (!$filterData instanceof QueryResult) {
254
            throw new InvalidArgumentException('Repository callback must return an instance of QueryResult');
255
        }
256
257
        // 6. Prepare the data
258
        /** @var ResponseInterface $response */
259 2
        $response = new $this->responseClassName;
260 2
        $response->setData($filterData->getResult());
261 2
        $response->addMeta('total_records', $filterData->getTotalRows());
262 2
        $response->addMeta('metrics', array(
263 2
            'query_and_transformation' => $duration,
264
        ));
265
266
        // 7. Return the data
267 2
        return $response;
268
    }
269
}
270