RepositoryHelper   A
last analyzed

Complexity

Total Complexity 32

Size/Duplication

Total Lines 384
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
eloc 81
dl 0
loc 384
ccs 93
cts 93
cp 1
rs 9.84
c 0
b 0
f 0
wmc 32

12 Methods

Rating   Name   Duplication   Size   Complexity  
A resetParameterCount() 0 3 1
A getExpression() 0 8 1
A processOrderBy() 0 10 3
A processCriteria() 0 16 2
A processExpression() 0 10 3
A processSearchTerms() 0 14 4
A createCriteria() 0 9 3
A buildExpression() 0 19 3
A getIterator() 0 9 3
A getParameters() 0 33 4
A getComparisonParameters() 0 21 2
A determineComparisonAndParameters() 0 23 3
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
The condition is_array($value) is always true.
Loading history...
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
Documentation Bug introduced by
The doc comment array<int|string, array<...>|mixed|string>|string> at position 8 could not be parsed: Unknown type name 'array-key' at position 8 in array<int|string, array<array-key, array<int, string>|mixed|string>|string>.
Loading history...
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