Completed
Push — master ( ad6910...d77fda )
by Tarmo
18s queued 12s
created

RepositoryHelper::getIterator()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

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