Passed
Push — master ( 8e43c9...4981d1 )
by Tarmo
116:46 queued 51:34
created

RepositoryHelper   A

Complexity

Total Complexity 32

Size/Duplication

Total Lines 385
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 2
Bugs 2 Features 0
Metric Value
wmc 32
eloc 81
c 2
b 2
f 0
dl 0
loc 385
ccs 93
cts 93
cp 1
rs 9.84
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 count;
24
use function is_array;
25
use function is_numeric;
26
use function str_contains;
27
use function strcmp;
28
use function strtolower;
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 56
    public static function resetParameterCount(): void
48
    {
49 56
        self::$parameterCount = 0;
50 56
    }
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, string|array>|null $criteria
88
     *
89
     * @throws InvalidArgumentException
90
     */
91
    public static function processCriteria(QueryBuilder $queryBuilder, ?array $criteria = null): void
92 121
    {
93
        $criteria ??= [];
94 121
95
        if (count($criteria) === 0) {
96 121
            return;
97 113
        }
98
99
        // Initialize condition array
100
        $condition = [];
101 8
102
        // Create used condition array
103
        array_walk($criteria, self::getIterator($condition));
104 8
105
        // And attach search term condition to main query
106
        $queryBuilder->andWhere(self::getExpression($queryBuilder, $queryBuilder->expr()->andX(), $condition));
107 8
    }
108 8
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
     * @param array<string, string>|null $terms
118
     *
119
     * @throws InvalidArgumentException
120
     */
121
    public static function processSearchTerms(QueryBuilder $queryBuilder, array $columns, ?array $terms = null): void
122 119
    {
123
        $terms ??= [];
124 119
125
        if (count($columns) === 0) {
126 119
            return;
127 24
        }
128
129
        // Iterate search term sets
130
        foreach ($terms as $operand => $search) {
131 95
            $criteria = SearchTerm::getCriteria($columns, $search, $operand);
132 6
133
            if ($criteria !== null) {
134 6
                $queryBuilder->andWhere(self::getExpression($queryBuilder, $queryBuilder->expr()->andX(), $criteria));
135 6
            }
136
        }
137
    }
138 95
139
    /**
140
     * Simple process method for order by part of for current query builder.
141
     *
142
     * @param array<string, string>|null $orderBy
143
     */
144
    public static function processOrderBy(QueryBuilder $queryBuilder, ?array $orderBy = null): void
145 119
    {
146
        $orderBy ??= [];
147 119
148
        foreach ($orderBy as $column => $order) {
149 119
            if (!str_contains($column, '.')) {
150 6
                $column = 'entity.' . $column;
151 3
            }
152
153
            $queryBuilder->addOrderBy($column, $order);
154 6
        }
155
    }
156 119
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, string|array> $criteria
232
     *
233
     * @throws InvalidArgumentException
234
     */
235
    public static function getExpression(
236 30
        QueryBuilder $queryBuilder,
237
        Composite $expression,
238
        array $criteria,
239
    ): Composite {
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected ')', expecting T_VARIABLE on line 239 at column 4
Loading history...
240
        self::processExpression($queryBuilder, $expression, $criteria);
241 30
242
        return $expression;
243 30
    }
244
245
    /**
246
     * @param array<int|string, string|array> $criteria
247
     *
248
     * @throws InvalidArgumentException
249
     */
250
    private static function processExpression(QueryBuilder $queryBuilder, Composite $expression, array $criteria): void
251 30
    {
252
        $iterator = static function (array $comparison, string $key) use ($queryBuilder, $expression): void {
253 30
            $expressionAnd = ($key === 'and' || array_key_exists('and', $comparison));
254 29
            $expressionOr = ($key === 'or' || array_key_exists('or', $comparison));
255 29
256
            self::buildExpression($queryBuilder, $expression, $expressionAnd, $expressionOr, $comparison);
257 29
        };
258 30
259
        array_walk($criteria, $iterator);
260 30
    }
261 30
262
    /**
263
     * @param array<int|string, string|array> $comparison
264
     *
265
     * @throws InvalidArgumentException
266
     */
267
    private static function buildExpression(
268 29
        QueryBuilder $queryBuilder,
269
        Composite $expression,
270
        bool $expressionAnd,
271
        bool $expressionOr,
272
        array $comparison
273
    ): void {
274
        if ($expressionAnd) {
275 29
            $expression->add(self::getExpression($queryBuilder, $queryBuilder->expr()->andX(), $comparison));
276 10
        } elseif ($expressionOr) {
277 29
            $expression->add(self::getExpression($queryBuilder, $queryBuilder->expr()->orX(), $comparison));
278 8
        } else {
279
            [$comparison, $parameters] = self::determineComparisonAndParameters($queryBuilder, $comparison);
280 29
281
            /** @var callable $callable */
282
            $callable = [$queryBuilder->expr(), $comparison->operator];
283 29
284
            // And finally add new expression to main one with specified parameters
285
            $expression->add(call_user_func_array($callable, $parameters));
286 29
        }
287
    }
288 29
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}
295
     */
296
    private static function createCriteria(string $column, string | array $value): array
297 4
    {
298
        if (!str_contains($column, '.')) {
299 4
            $column = 'entity.' . $column;
300 3
        }
301
302
        $operator = is_array($value) ? 'in' : 'eq';
303 4
304
        return [$column, $operator, $value];
305 4
    }
306
307
    /**
308
     * @param array<int|string, string|array> $comparison
309
     *
310
     * @return array<int, mixed>
311
     */
312
    private static function determineComparisonAndParameters(QueryBuilder $queryBuilder, array $comparison): array
313 29
    {
314
        $comparisonObject = (object)array_combine(['field', 'operator', 'value'], $comparison);
315 29
316
        // Increase parameter count
317
        self::$parameterCount++;
318 29
319
        // Initialize used callback parameters
320
        $parameters = [$comparisonObject->field];
321 29
322
        $lowercaseOperator = strtolower($comparisonObject->operator);
323 29
324
        if (!($lowercaseOperator === 'isnull' || $lowercaseOperator === 'isnotnull')) {
325 29
            $parameters = self::getComparisonParameters(
326 27
                $queryBuilder,
327 27
                $comparisonObject,
328
                $lowercaseOperator,
329
                $parameters
330
            );
331
        }
332
333
        return [$comparisonObject, $parameters];
334 29
    }
335
336
    /**
337
     * @param array<int, string> $parameters
338
     * @param array<int, mixed> $value
339
     *
340
     * @return array<int, array<int, Literal>|string>
341
     */
342
    private static function getParameters(
343 3
        QueryBuilder $queryBuilder,
344
        string $lowercaseOperator,
345
        array $parameters,
346
        array $value
347
    ): array {
348
        // Operator is between, so we need to add third parameter for Expr method
349
        if ($lowercaseOperator === 'between') {
350 3
            $parameters[] = '?' . self::$parameterCount;
351 1
            $queryBuilder->setParameter(self::$parameterCount, $value[0], UuidHelper::getType((string)$value[0]));
352 1
353
            self::$parameterCount++;
354 1
355
            $parameters[] = '?' . self::$parameterCount;
356 1
            $queryBuilder->setParameter(self::$parameterCount, $value[1], UuidHelper::getType((string)$value[1]));
357 1
        } else {
358
            // Otherwise this must be IN or NOT IN expression
359
            try {
360
                $value = array_map([UuidHelper::class, 'getBytes'], $value);
361 2
            } catch (InvalidUuidStringException $error) {
362 2
                (static fn (InvalidUuidStringException $error): InvalidUuidStringException => $error)($error);
363 2
            }
364
365
            $parameters[] = array_map(
366 2
                static fn (string $value): Literal => $queryBuilder->expr()->literal(
367 2
                    is_numeric($value) ? (int)$value : $value
368 2
                ),
369 2
                $value
370 2
            );
371
        }
372
373
        return $parameters;
374 3
    }
375
376
    /**
377
     * @param array<int|string, string|array> $condition
378
     */
379
    private static function getIterator(array &$condition): Closure
380
    {
381
        return static function (string | array $value, string $column) use (&$condition): void {
382 8
            // If criteria contains 'and' OR 'or' key(s) assume that array in only in the right format
383
            if (strcmp($column, 'and') === 0 || strcmp($column, 'or') === 0) {
384 8
                $condition[$column] = $value;
385
            } else {
386 8
                // Add condition
387 4
                $condition[] = self::createCriteria($column, $value);
388
            }
389
        };
390 4
    }
391
392 8
    /**
393
     * @param array<int, string> $parameters
394
     *
395
     * @return array<int, array<int, Literal>|string>
396
     */
397
    private static function getComparisonParameters(
398
        QueryBuilder $queryBuilder,
399
        stdClass $comparison,
400 27
        string $lowercaseOperator,
401
        array $parameters
402
    ): array {
403
        if (is_array($comparison->value)) {
404
            $value = $comparison->value;
405
406 27
            $parameters = self::getParameters($queryBuilder, $lowercaseOperator, $parameters, $value);
407 3
        } else {
408
            $parameters[] = '?' . self::$parameterCount;
409 3
410
            $queryBuilder->setParameter(
411 24
                self::$parameterCount,
412
                $comparison->value,
413 24
                UuidHelper::getType((string)$comparison->value)
414 24
            );
415 24
        }
416 24
417
        return $parameters;
418
    }
419
}
420