tarlepp /
symfony-flex-backend
| 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
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
|
|||
| 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 |