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)) { |
|
|
|
|
214
|
|
|
// $searchByExtra = $searchByExtra($searchBy); |
|
|
|
|
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
|
|
|
|
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.