1 | <?php |
||
2 | declare(strict_types = 1); |
||
3 | /** |
||
4 | * /src/Rest/RepositoryHelper.php |
||
5 | * |
||
6 | * @author TLe, Tarmo Leppänen <[email protected]> |
||
7 | */ |
||
8 | |||
9 | namespace App\Rest; |
||
10 | |||
11 | use Closure; |
||
12 | use Doctrine\ORM\Query\Expr\Composite; |
||
13 | use Doctrine\ORM\Query\Expr\Literal; |
||
14 | use Doctrine\ORM\QueryBuilder; |
||
15 | use InvalidArgumentException; |
||
16 | use Ramsey\Uuid\Exception\InvalidUuidStringException; |
||
17 | use stdClass; |
||
18 | use function array_combine; |
||
19 | use function array_key_exists; |
||
20 | use function array_map; |
||
21 | use function array_walk; |
||
22 | use function call_user_func_array; |
||
23 | use function is_array; |
||
24 | use function is_numeric; |
||
25 | use function str_contains; |
||
26 | use function strcmp; |
||
27 | use function strtolower; |
||
28 | use function syslog; |
||
29 | |||
30 | /** |
||
31 | * Class RepositoryHelper |
||
32 | * |
||
33 | * @package App\Rest |
||
34 | * @author TLe, Tarmo Leppänen <[email protected]> |
||
35 | */ |
||
36 | class RepositoryHelper |
||
37 | { |
||
38 | /** |
||
39 | * Parameter count in current query, this is used to track parameters which |
||
40 | * are bind to current query. |
||
41 | */ |
||
42 | public static int $parameterCount = 0; |
||
43 | |||
44 | /** |
||
45 | * Method to reset current parameter count value |
||
46 | */ |
||
47 | 96 | public static function resetParameterCount(): void |
|
48 | { |
||
49 | 96 | self::$parameterCount = 0; |
|
50 | } |
||
51 | |||
52 | /** |
||
53 | * Process given criteria which is given by ?where parameter. This is given |
||
54 | * as JSON string, which is converted to assoc array for this process. |
||
55 | * |
||
56 | * Note that this supports by default (without any extra work) just 'eq' |
||
57 | * and 'in' expressions. See example array below: |
||
58 | * |
||
59 | * [ |
||
60 | * 'u.id' => 3, |
||
61 | * 'u.uid' => 'uid', |
||
62 | * 'u.foo' => [1, 2, 3], |
||
63 | * 'u.bar' => ['foo', 'bar'], |
||
64 | * ] |
||
65 | * |
||
66 | * And these you can make easily happen within REST controller and simple |
||
67 | * 'where' parameter. See example below: |
||
68 | * |
||
69 | * ?where={"u.id":3,"u.uid":"uid","u.foo":[1,2,3],"u.bar":["foo","bar"]} |
||
70 | * |
||
71 | * Also note that you can make more complex use case fairly easy, just |
||
72 | * follow instructions below. |
||
73 | * |
||
74 | * If you're trying to make controller specified special criteria with |
||
75 | * projects generic Rest controller, just add 'processCriteria(array &$criteria)' |
||
76 | * method to your own controller and pre-process that criteria in there |
||
77 | * the way you want it to be handled. In other words just modify that basic |
||
78 | * key-value array just as you like it, main goal is to create array that |
||
79 | * is compatible with 'getExpression' method in this class. For greater |
||
80 | * detail just see that method comments. |
||
81 | * |
||
82 | * tl;dr Modify your $criteria parameter in your controller with |
||
83 | * 'processCriteria(array &$criteria)' method. |
||
84 | * |
||
85 | * @see \App\Repository\Traits\RepositoryMethodsTrait::getQueryBuilder() |
||
86 | * |
||
87 | * @param array<int|string, mixed>|null $criteria |
||
88 | * |
||
89 | * @throws InvalidArgumentException |
||
90 | */ |
||
91 | 142 | public static function processCriteria(QueryBuilder $queryBuilder, ?array $criteria = null): void |
|
92 | { |
||
93 | 142 | $criteria ??= []; |
|
94 | |||
95 | 142 | if ($criteria === []) { |
|
96 | 134 | return; |
|
97 | } |
||
98 | |||
99 | // Initialize condition array |
||
100 | 8 | $condition = []; |
|
101 | |||
102 | // Create used condition array |
||
103 | 8 | array_walk($criteria, self::getIterator($condition)); |
|
104 | |||
105 | // And attach search term condition to main query |
||
106 | 8 | $queryBuilder->andWhere(self::getExpression($queryBuilder, $queryBuilder->expr()->andX(), $condition)); |
|
107 | } |
||
108 | |||
109 | /** |
||
110 | * Helper method to process given search terms and create criteria about |
||
111 | * those. Note that each repository has 'searchColumns' property which |
||
112 | * contains the fields where search term will be affected. |
||
113 | * |
||
114 | * @see \App\Repository\Traits\RepositoryMethodsTrait::getQueryBuilder() |
||
115 | * |
||
116 | * @param array<int, string> $columns |
||
117 | * @phpstan-param array<mixed>|null $terms |
||
118 | * |
||
119 | * @throws InvalidArgumentException |
||
120 | */ |
||
121 | 140 | public static function processSearchTerms(QueryBuilder $queryBuilder, array $columns, ?array $terms = null): void |
|
122 | { |
||
123 | 140 | $terms ??= []; |
|
124 | |||
125 | 140 | if ($columns === []) { |
|
126 | 24 | return; |
|
127 | } |
||
128 | |||
129 | // Iterate search term sets |
||
130 | 116 | foreach ($terms as $operand => $search) { |
|
131 | 6 | $criteria = SearchTerm::getCriteria($columns, $search, $operand); |
|
132 | |||
133 | 6 | if ($criteria !== null) { |
|
134 | 6 | $queryBuilder->andWhere(self::getExpression($queryBuilder, $queryBuilder->expr()->andX(), $criteria)); |
|
135 | } |
||
136 | } |
||
137 | } |
||
138 | |||
139 | /** |
||
140 | * Simple process method for order by part of for current query builder. |
||
141 | * |
||
142 | * @param array<string, string>|null $orderBy |
||
143 | */ |
||
144 | 140 | public static function processOrderBy(QueryBuilder $queryBuilder, ?array $orderBy = null): void |
|
145 | { |
||
146 | 140 | $orderBy ??= []; |
|
147 | |||
148 | 140 | foreach ($orderBy as $column => $order) { |
|
149 | 6 | if (!str_contains($column, '.')) { |
|
150 | 3 | $column = 'entity.' . $column; |
|
151 | } |
||
152 | |||
153 | 6 | $queryBuilder->addOrderBy($column, $order); |
|
154 | } |
||
155 | } |
||
156 | |||
157 | /** |
||
158 | * Recursively takes the specified criteria and adds too the expression. |
||
159 | * |
||
160 | * The criteria is defined in an array notation where each item in the list |
||
161 | * represents a comparison <fieldName, operator, value>. The operator maps |
||
162 | * to comparison methods located in ExpressionBuilder. The key in the array |
||
163 | * can be used to identify grouping of comparisons. |
||
164 | * |
||
165 | * Currently supported Doctrine\ORM\Query\Expr methods: |
||
166 | * |
||
167 | * OPERATOR EXAMPLE INPUT ARRAY GENERATED QUERY RESULT NOTES |
||
168 | * eq ['u.id', 'eq', 123] u.id = ?1 - |
||
169 | * neq ['u.id', 'neq', 123] u.id <> ?1 - |
||
170 | * lt ['u.id', 'lt', 123] u.id < ?1 - |
||
171 | * lte ['u.id', 'lte', 123] u.id <= ?1 - |
||
172 | * gt ['u.id', 'gt', 123] u.id > ?1 - |
||
173 | * gte ['u.id', 'gte', 123] u.id >= ?1 - |
||
174 | * in ['u.id', 'in', [1,2]] u.id IN (1,2) third value may contain n values |
||
175 | * notIn ['u.id', 'notIn', [1,2]] u.id NOT IN (1,2) third value may contain n values |
||
176 | * isNull ['u.id', 'isNull', null] u.id IS NULL third value must be set, but not used |
||
177 | * isNotNull ['u.id', 'isNotNull', null] u.id IS NOT NULL third value must be set, but not used |
||
178 | * like ['u.id', 'like', 'abc'] u.id LIKE ?1 - |
||
179 | * notLike ['u.id', 'notLike', 'abc'] u.id NOT LIKE ?1 - |
||
180 | * between ['u.id', 'between', [1,6]] u.id BETWEEN ?1 AND ?2 third value must contain two values |
||
181 | * |
||
182 | * Also note that you can easily combine 'and' and 'or' queries like |
||
183 | * following examples: |
||
184 | * |
||
185 | * EXAMPLE INPUT ARRAY GENERATED QUERY RESULT |
||
186 | * [ |
||
187 | * 'and' => [ |
||
188 | * ['u.firstName', 'eq', 'foo bar'] |
||
189 | * ['u.lastName', 'neq', 'not this one'] |
||
190 | * ] |
||
191 | * ] (u.firstName = ?1 AND u.lastName <> ?2) |
||
192 | * [ |
||
193 | * 'or' => [ |
||
194 | * ['u.firstName', 'eq', 'foo bar'] |
||
195 | * ['u.lastName', 'neq', 'not this one'] |
||
196 | * ] |
||
197 | * ] (u.firstName = ?1 OR u.lastName <> ?2) |
||
198 | * |
||
199 | * Also note that you can nest these criteria arrays as many levels as you |
||
200 | * need - only the sky is the limit... |
||
201 | * |
||
202 | * @example |
||
203 | * $criteria = [ |
||
204 | * 'or' => [ |
||
205 | * ['entity.field1', 'like', '%field1Value%'], |
||
206 | * ['entity.field2', 'like', '%field2Value%'], |
||
207 | * ], |
||
208 | * 'and' => [ |
||
209 | * ['entity.field3', 'eq', 3], |
||
210 | * ['entity.field4', 'eq', 'four'], |
||
211 | * ], |
||
212 | * ['entity.field5', 'neq', 5], |
||
213 | * ]; |
||
214 | * |
||
215 | * $qb = $this->createQueryBuilder('entity'); |
||
216 | * $qb->where($this->getExpression($qb, $qb->expr()->andX(), $criteria)); |
||
217 | * $query = $qb->getQuery(); |
||
218 | * echo $query->getSQL(); |
||
219 | * |
||
220 | * // Result: |
||
221 | * // SELECT * |
||
222 | * // FROM tableName |
||
223 | * // WHERE ((field1 LIKE '%field1Value%') OR (field2 LIKE '%field2Value%')) |
||
224 | * // AND ((field3 = '3') AND (field4 = 'four')) |
||
225 | * // AND (field5 <> '5') |
||
226 | * |
||
227 | * Also note that you can nest these queries as many times as you wish... |
||
228 | * |
||
229 | * @see https://gist.github.com/jgornick/8671644 |
||
230 | * |
||
231 | * @param array<int|string, mixed> $criteria |
||
232 | * |
||
233 | * @throws InvalidArgumentException |
||
234 | */ |
||
235 | 30 | public static function getExpression( |
|
236 | QueryBuilder $queryBuilder, |
||
237 | Composite $expression, |
||
238 | array $criteria, |
||
239 | ): Composite { |
||
240 | 30 | self::processExpression($queryBuilder, $expression, $criteria); |
|
241 | |||
242 | 30 | return $expression; |
|
243 | } |
||
244 | |||
245 | /** |
||
246 | * @param array<int|string, mixed> $criteria |
||
247 | * |
||
248 | * @throws InvalidArgumentException |
||
249 | */ |
||
250 | 30 | private static function processExpression(QueryBuilder $queryBuilder, Composite $expression, array $criteria): void |
|
251 | { |
||
252 | 30 | $iterator = static function (array $comparison, string | int $key) use ($queryBuilder, $expression): void { |
|
253 | 29 | $expressionAnd = ($key === 'and' || array_key_exists('and', $comparison)); |
|
254 | 29 | $expressionOr = ($key === 'or' || array_key_exists('or', $comparison)); |
|
255 | |||
256 | 29 | self::buildExpression($queryBuilder, $expression, $expressionAnd, $expressionOr, $comparison); |
|
257 | 30 | }; |
|
258 | |||
259 | 30 | array_walk($criteria, $iterator); |
|
260 | } |
||
261 | |||
262 | /** |
||
263 | * @param array<int|string, mixed> $comparison |
||
264 | * |
||
265 | * @throws InvalidArgumentException |
||
266 | */ |
||
267 | 29 | private static function buildExpression( |
|
268 | QueryBuilder $queryBuilder, |
||
269 | Composite $expression, |
||
270 | bool $expressionAnd, |
||
271 | bool $expressionOr, |
||
272 | array $comparison |
||
273 | ): void { |
||
274 | 29 | if ($expressionAnd) { |
|
275 | 10 | $expression->add(self::getExpression($queryBuilder, $queryBuilder->expr()->andX(), $comparison)); |
|
276 | 29 | } elseif ($expressionOr) { |
|
277 | 8 | $expression->add(self::getExpression($queryBuilder, $queryBuilder->expr()->orX(), $comparison)); |
|
278 | } else { |
||
279 | 29 | [$comparison, $parameters] = self::determineComparisonAndParameters($queryBuilder, $comparison); |
|
280 | |||
281 | /** @var callable $callable */ |
||
282 | 29 | $callable = [$queryBuilder->expr(), $comparison->operator]; |
|
283 | |||
284 | // And finally add new expression to main one with specified parameters |
||
285 | 29 | $expression->add(call_user_func_array($callable, $parameters)); |
|
286 | } |
||
287 | } |
||
288 | |||
289 | /** |
||
290 | * Lambda function to create condition array for 'getExpression' method. |
||
291 | * |
||
292 | * @param string|array<int, string> $value |
||
293 | * |
||
294 | * @return array{0: string, 1: string, 2: string|array<int, string>} |
||
295 | */ |
||
296 | 4 | private static function createCriteria(string $column, string | array $value): array |
|
297 | { |
||
298 | 4 | if (!str_contains($column, '.')) { |
|
299 | 3 | $column = 'entity.' . $column; |
|
300 | } |
||
301 | |||
302 | 4 | $operator = is_array($value) ? 'in' : 'eq'; |
|
0 ignored issues
–
show
introduced
by
![]() |
|||
303 | |||
304 | 4 | return [$column, $operator, $value]; |
|
305 | } |
||
306 | |||
307 | /** |
||
308 | * @param array<int|string, string|array<mixed>> $comparison |
||
309 | * |
||
310 | * @return array<int, mixed> |
||
311 | */ |
||
312 | 29 | private static function determineComparisonAndParameters(QueryBuilder $queryBuilder, array $comparison): array |
|
313 | { |
||
314 | /** @var stdClass $comparisonObject */ |
||
315 | 29 | $comparisonObject = (object)array_combine(['field', 'operator', 'value'], $comparison); |
|
316 | |||
317 | // Increase parameter count |
||
318 | 29 | self::$parameterCount++; |
|
319 | |||
320 | // Initialize used callback parameters |
||
321 | 29 | $parameters = [$comparisonObject->field]; |
|
322 | |||
323 | 29 | $lowercaseOperator = strtolower((string)$comparisonObject->operator); |
|
324 | |||
325 | 29 | if ($lowercaseOperator !== 'isnull' && $lowercaseOperator !== 'isnotnull') { |
|
326 | 27 | $parameters = self::getComparisonParameters( |
|
327 | 27 | $queryBuilder, |
|
328 | 27 | $comparisonObject, |
|
329 | 27 | $lowercaseOperator, |
|
330 | 27 | $parameters |
|
331 | 27 | ); |
|
332 | } |
||
333 | |||
334 | 29 | return [$comparisonObject, $parameters]; |
|
335 | } |
||
336 | |||
337 | /** |
||
338 | * @param array<int, string> $parameters |
||
339 | * @param array<int, mixed> $value |
||
340 | * |
||
341 | * @return array<int, array<int, Literal>|string> |
||
342 | */ |
||
343 | 3 | private static function getParameters( |
|
344 | QueryBuilder $queryBuilder, |
||
345 | string $lowercaseOperator, |
||
346 | array $parameters, |
||
347 | array $value, |
||
348 | ): array { |
||
349 | // Operator is between, so we need to add third parameter for Expr method |
||
350 | 3 | if ($lowercaseOperator === 'between') { |
|
351 | 1 | $parameters[] = '?' . self::$parameterCount; |
|
352 | 1 | $queryBuilder->setParameter(self::$parameterCount, $value[0], UuidHelper::getType((string)$value[0])); |
|
353 | |||
354 | 1 | self::$parameterCount++; |
|
355 | |||
356 | 1 | $parameters[] = '?' . self::$parameterCount; |
|
357 | 1 | $queryBuilder->setParameter(self::$parameterCount, $value[1], UuidHelper::getType((string)$value[1])); |
|
358 | } else { |
||
359 | // Otherwise this must be IN or NOT IN expression |
||
360 | try { |
||
361 | 2 | $value = array_map(static fn (string $value): string => UuidHelper::getBytes($value), $value); |
|
362 | 2 | } catch (InvalidUuidStringException $exception) { |
|
363 | // Ok so value isn't list of UUIDs |
||
364 | 2 | syslog(LOG_INFO, $exception->getMessage()); |
|
365 | } |
||
366 | |||
367 | 2 | $parameters[] = array_map( |
|
368 | 2 | static fn (string $value): Literal => $queryBuilder->expr()->literal( |
|
369 | 2 | is_numeric($value) ? (int)$value : $value |
|
370 | 2 | ), |
|
371 | 2 | $value |
|
372 | 2 | ); |
|
373 | } |
||
374 | |||
375 | 3 | return $parameters; |
|
376 | } |
||
377 | |||
378 | /** |
||
379 | * @param array<int|string, array<array-key, array<int, string>|mixed|string>|string> $condition |
||
0 ignored issues
–
show
|
|||
380 | */ |
||
381 | 8 | private static function getIterator(array &$condition): Closure |
|
382 | { |
||
383 | 8 | return static function (string | array $value, string $column) use (&$condition): void { |
|
384 | // If criteria contains 'and' OR 'or' key(s) assume that array in only in the right format |
||
385 | 8 | if (strcmp($column, 'and') === 0 || strcmp($column, 'or') === 0) { |
|
386 | 4 | $condition[$column] = $value; |
|
387 | } else { |
||
388 | // Add condition |
||
389 | 4 | $condition[] = self::createCriteria($column, $value); |
|
390 | } |
||
391 | 8 | }; |
|
392 | } |
||
393 | |||
394 | /** |
||
395 | * @param array<int, string> $parameters |
||
396 | * |
||
397 | * @return array<int, array<int, Literal>|string> |
||
398 | */ |
||
399 | 27 | private static function getComparisonParameters( |
|
400 | QueryBuilder $queryBuilder, |
||
401 | stdClass $comparison, |
||
402 | string $lowercaseOperator, |
||
403 | array $parameters, |
||
404 | ): array { |
||
405 | 27 | if (is_array($comparison->value)) { |
|
406 | 3 | $value = $comparison->value; |
|
407 | |||
408 | 3 | $parameters = self::getParameters($queryBuilder, $lowercaseOperator, $parameters, $value); |
|
409 | } else { |
||
410 | 24 | $parameters[] = '?' . self::$parameterCount; |
|
411 | |||
412 | 24 | $queryBuilder->setParameter( |
|
413 | 24 | self::$parameterCount, |
|
414 | 24 | $comparison->value, |
|
415 | 24 | UuidHelper::getType((string)$comparison->value) |
|
416 | 24 | ); |
|
417 | } |
||
418 | |||
419 | 27 | return $parameters; |
|
420 | } |
||
421 | } |
||
422 |