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'; |
|
|
|
|
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 |
|
|
|
|
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
|
|
|
|