Completed
Pull Request — master (#8105)
by
unknown
10:29
created

LimitSubqueryOutputWalker::getSQLIdentifier()   B

Complexity

Conditions 11
Paths 19

Size

Total Lines 61
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 19.1097

Importance

Changes 0
Metric Value
cc 11
eloc 31
c 0
b 0
f 0
nc 19
nop 1
dl 0
loc 61
ccs 19
cts 32
cp 0.5938
crap 19.1097
rs 7.3166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Doctrine\ORM\Tools\Pagination;
6
7
use Doctrine\DBAL\Platforms\AbstractPlatform;
8
use Doctrine\DBAL\Platforms\DB2Platform;
9
use Doctrine\DBAL\Platforms\OraclePlatform;
10
use Doctrine\DBAL\Platforms\PostgreSQL100Platform;
11
use Doctrine\DBAL\Platforms\PostgreSQL94Platform;
12
use Doctrine\DBAL\Platforms\SQLAnywhere16Platform;
13
use Doctrine\DBAL\Platforms\SQLServer2012Platform;
14
use Doctrine\DBAL\Types\Type;
15
use Doctrine\ORM\EntityManagerInterface;
16
use Doctrine\ORM\Mapping\AssociationMetadata;
17
use Doctrine\ORM\Mapping\FieldMetadata;
18
use Doctrine\ORM\OptimisticLockException;
19
use Doctrine\ORM\Query;
20
use Doctrine\ORM\Query\AST\OrderByClause;
21
use Doctrine\ORM\Query\AST\PartialObjectExpression;
22
use Doctrine\ORM\Query\AST\PathExpression;
23
use Doctrine\ORM\Query\AST\SelectExpression;
24
use Doctrine\ORM\Query\AST\SelectStatement;
25
use Doctrine\ORM\Query\ParserResult;
26
use Doctrine\ORM\Query\QueryException;
27
use Doctrine\ORM\Query\ResultSetMapping;
28
use Doctrine\ORM\Query\SqlWalker;
29
use RuntimeException;
30
use function array_diff;
31
use function array_keys;
32
use function array_map;
33
use function count;
34
use function implode;
35
use function in_array;
36
use function is_string;
37
use function method_exists;
38
use function preg_replace;
39
use function reset;
40
use function sprintf;
41
use function strrpos;
42
use function substr;
43
44
/**
45
 * Wraps the query in order to select root entity IDs for pagination.
46
 *
47
 * Given a DQL like `SELECT u FROM User u` it will generate an SQL query like:
48
 * SELECT DISTINCT <id> FROM (<original SQL>) LIMIT x OFFSET y
49
 *
50
 * Works with composite keys but cannot deal with queries that have multiple
51
 * root entities (e.g. `SELECT f, b from Foo, Bar`)
52
 */
53
class LimitSubqueryOutputWalker extends SqlWalker
54
{
55
    private const ORDER_BY_PATH_EXPRESSION = '/(?<![a-z0-9_])%s\.%s(?![a-z0-9_])/i';
56
57
    /** @var AbstractPlatform */
58
    private $platform;
59
60
    /** @var ResultSetMapping */
61
    private $rsm;
62
63
    /** @var mixed[][] */
64
    private $queryComponents;
65
66
    /** @var int */
67
    private $firstResult;
68
69
    /** @var int */
70
    private $maxResults;
71
72
    /** @var EntityManagerInterface */
73
    private $em;
74
75
    /** @var PathExpression[] */
76
    private $orderByPathExpressions = [];
77
78
    /**
79
     * @var bool We don't want to add path expressions from sub-selects into the select clause of the containing query.
80
     *           This state flag simply keeps track on whether we are walking on a subquery or not
81
     */
82
    private $inSubSelect = false;
83
84
    /**
85
     * Stores various parameters that are otherwise unavailable
86
     * because Doctrine\ORM\Query\SqlWalker keeps everything private without
87
     * accessors.
88
     *
89
     * @param Query        $query
90
     * @param ParserResult $parserResult
91
     * @param mixed[][]    $queryComponents
92
     */
93 25
    public function __construct($query, $parserResult, array $queryComponents)
94
    {
95 25
        $this->platform        = $query->getEntityManager()->getConnection()->getDatabasePlatform();
96 25
        $this->rsm             = $parserResult->getResultSetMapping();
97 25
        $this->queryComponents = $queryComponents;
98
99
        // Reset limit and offset
100 25
        $this->firstResult = $query->getFirstResult();
101 25
        $this->maxResults  = $query->getMaxResults();
102
103
        $query
104 25
            ->setFirstResult(null)
105 25
            ->setMaxResults(null);
106
107 25
        $this->em = $query->getEntityManager();
108
109 25
        parent::__construct($query, $parserResult, $queryComponents);
110 25
    }
111
112
    /**
113
     * Check if the platform supports the ROW_NUMBER window function.
114
     *
115
     * @return bool
116
     */
117 25
    private function platformSupportsRowNumber()
118
    {
119 25
        return $this->platform instanceof PostgreSQL94Platform
120 18
            || $this->platform instanceof PostgreSQL100Platform
121 18
            || $this->platform instanceof SQLServer2012Platform
122 18
            || $this->platform instanceof OraclePlatform
123 12
            || $this->platform instanceof SQLAnywhere16Platform
124 12
            || $this->platform instanceof DB2Platform
125 12
            || (method_exists($this->platform, 'supportsRowNumberFunction')
126 25
                && $this->platform->supportsRowNumberFunction());
127
    }
128
129
    /**
130
     * Rebuilds a select statement's order by clause for use in a
131
     * ROW_NUMBER() OVER() expression.
132
     */
133 11
    private function rebuildOrderByForRowNumber(SelectStatement $AST)
134
    {
135 11
        $orderByClause              = $AST->orderByClause;
136 11
        $selectAliasToExpressionMap = [];
137
        // Get any aliases that are available for select expressions.
138 11
        foreach ($AST->selectClause->selectExpressions as $selectExpression) {
139 11
            $selectAliasToExpressionMap[$selectExpression->fieldIdentificationVariable] = $selectExpression->expression;
140
        }
141
142
        // Rebuild string orderby expressions to use the select expression they're referencing
143 11
        foreach ($orderByClause->orderByItems as $orderByItem) {
144 11
            if (is_string($orderByItem->expression) && isset($selectAliasToExpressionMap[$orderByItem->expression])) {
145 7
                $orderByItem->expression = $selectAliasToExpressionMap[$orderByItem->expression];
146
            }
147
        }
148
149 11
        $func = new RowNumberOverFunction('dctrn_rownum');
150
151 11
        $func->orderByClause                    = $AST->orderByClause;
152 11
        $AST->selectClause->selectExpressions[] = new SelectExpression($func, 'dctrn_rownum', true);
153
154
        // No need for an order by clause, we'll order by rownum in the outer query.
155 11
        $AST->orderByClause = null;
156 11
    }
157
158
    /**
159
     * Walks down a SelectStatement AST node, wrapping it in a SELECT DISTINCT.
160
     *
161
     * @return string
162
     *
163
     * @throws RuntimeException
164
     */
165 25
    public function walkSelectStatement(SelectStatement $AST)
166
    {
167 25
        if ($this->platformSupportsRowNumber()) {
168 13
            return $this->walkSelectStatementWithRowNumber($AST);
169
        }
170
171 12
        return $this->walkSelectStatementWithoutRowNumber($AST);
172
    }
173
174
    /**
175
     * Walks down a SelectStatement AST node, wrapping it in a SELECT DISTINCT.
176
     * This method is for use with platforms which support ROW_NUMBER.
177
     *
178
     * @return string
179
     *
180
     * @throws RuntimeException
181
     */
182 13
    public function walkSelectStatementWithRowNumber(SelectStatement $AST)
183
    {
184 13
        $hasOrderBy   = false;
185 13
        $outerOrderBy = ' ORDER BY dctrn_minrownum ASC';
186 13
        $orderGroupBy = '';
187
188 13
        if ($AST->orderByClause instanceof OrderByClause) {
189 11
            $hasOrderBy = true;
190
191 11
            $this->rebuildOrderByForRowNumber($AST);
192
        }
193
194 13
        $innerSql           = $this->getInnerSQL($AST);
195 13
        $sqlIdentifier      = $this->getSQLIdentifier($AST);
196
        $sqlAliasIdentifier = array_map(static function ($info) {
197 13
            return $info['alias'];
198 13
        }, $sqlIdentifier);
199
200 13
        if ($hasOrderBy) {
201 11
            $orderGroupBy = ' GROUP BY ' . implode(', ', $sqlAliasIdentifier);
202 11
            $sqlPiece     = 'MIN(' . $this->walkResultVariable('dctrn_rownum') . ') AS dctrn_minrownum';
203
204 11
            $sqlAliasIdentifier[] = $sqlPiece;
205 11
            $sqlIdentifier[]      = [
206 11
                'alias' => $sqlPiece,
207 11
                'type'  => Type::getType('integer'),
208
            ];
209
        }
210
211
        // Build the counter query
212 13
        $sql = sprintf('SELECT DISTINCT %s FROM (%s) dctrn_result', implode(', ', $sqlAliasIdentifier), $innerSql);
213
214 13
        if ($hasOrderBy) {
215 11
            $sql .= $orderGroupBy . $outerOrderBy;
216
        }
217
218
        // Apply the limit and offset.
219 13
        $sql = $this->platform->modifyLimitQuery($sql, $this->maxResults, $this->firstResult ?? 0);
220
221
        // Add the columns to the ResultSetMapping. It's not really nice but
222
        // it works. Preferably I'd clear the RSM or simply create a new one
223
        // but that is not possible from inside the output walker, so we dirty
224
        // up the one we have.
225 13
        foreach ($sqlIdentifier as $property => $propertyMapping) {
226 13
            $this->rsm->addScalarResult($propertyMapping['alias'], $property, $propertyMapping['type']);
227
        }
228
229 13
        return $sql;
230
    }
231
232
    /**
233
     * Walks down a SelectStatement AST node, wrapping it in a SELECT DISTINCT.
234
     * This method is for platforms which DO NOT support ROW_NUMBER.
235
     *
236
     * @param bool $addMissingItemsFromOrderByToSelect
237
     *
238
     * @return string
239
     *
240
     * @throws RuntimeException
241
     */
242 12
    public function walkSelectStatementWithoutRowNumber(SelectStatement $AST, $addMissingItemsFromOrderByToSelect = true)
243
    {
244
        // We don't want to call this recursively!
245 12
        if ($AST->orderByClause instanceof OrderByClause && $addMissingItemsFromOrderByToSelect) {
246
            // In the case of ordering a query by columns from joined tables, we
247
            // must add those columns to the select clause of the query BEFORE
248
            // the SQL is generated.
249 10
            $this->addMissingItemsFromOrderByToSelect($AST);
250
        }
251
252
        // Remove order by clause from the inner query
253
        // It will be re-appended in the outer select generated by this method
254 12
        $orderByClause      = $AST->orderByClause;
255 12
        $AST->orderByClause = null;
256
257 12
        $innerSql           = $this->getInnerSQL($AST);
258 12
        $sqlIdentifier      = $this->getSQLIdentifier($AST);
259
        $sqlAliasIdentifier = array_map(static function ($info) {
260 12
            return $info['alias'];
261 12
        }, $sqlIdentifier);
262
263
        // Build the counter query
264 12
        $sql = sprintf('SELECT DISTINCT %s FROM (%s) dctrn_result', implode(', ', $sqlAliasIdentifier), $innerSql);
265
266
        // http://www.doctrine-project.org/jira/browse/DDC-1958
267 12
        $sql = $this->preserveSqlOrdering($sqlAliasIdentifier, $innerSql, $sql, $orderByClause);
268
269
        // Apply the limit and offset.
270 12
        $sql = $this->platform->modifyLimitQuery(
271 12
            $sql,
272 12
            $this->maxResults,
273 12
            $this->firstResult ?? 0
274
        );
275
276
        // Add the columns to the ResultSetMapping. It's not really nice but
277
        // it works. Preferably I'd clear the RSM or simply create a new one
278
        // but that is not possible from inside the output walker, so we dirty
279
        // up the one we have.
280 12
        foreach ($sqlIdentifier as $property => $propertyMapping) {
281 12
            $this->rsm->addScalarResult($propertyMapping['alias'], $property, $propertyMapping['type']);
282
        }
283
284
        // Restore orderByClause
285 12
        $AST->orderByClause = $orderByClause;
286
287 12
        return $sql;
288
    }
289
290
    /**
291
     * Finds all PathExpressions in an AST's OrderByClause, and ensures that
292
     * the referenced fields are present in the SelectClause of the passed AST.
293
     */
294 10
    private function addMissingItemsFromOrderByToSelect(SelectStatement $AST)
295
    {
296 10
        $this->orderByPathExpressions = [];
297
298
        // We need to do this in another walker because otherwise we'll end up
299
        // polluting the state of this one.
300 10
        $walker = clone $this;
301
302
        // This will populate $orderByPathExpressions via
303
        // LimitSubqueryOutputWalker::walkPathExpression, which will be called
304
        // as the select statement is walked. We'll end up with an array of all
305
        // path expressions referenced in the query.
306 10
        $walker->walkSelectStatementWithoutRowNumber($AST, false);
307 10
        $orderByPathExpressions = $walker->getOrderByPathExpressions();
308
309
        // Get a map of referenced identifiers to field names.
310 10
        $selects = [];
311
312 10
        foreach ($orderByPathExpressions as $pathExpression) {
313 7
            $idVar = $pathExpression->identificationVariable;
314 7
            $field = $pathExpression->field;
315
316 7
            if (! isset($selects[$idVar])) {
317 7
                $selects[$idVar] = [];
318
            }
319
320 7
            $selects[$idVar][$field] = true;
321
        }
322
323
        // Loop the select clause of the AST and exclude items from $select
324
        // that are already being selected in the query.
325 10
        foreach ($AST->selectClause->selectExpressions as $selectExpression) {
326 10
            if ($selectExpression instanceof SelectExpression) {
327 10
                $idVar = $selectExpression->expression;
328
329 10
                if (! is_string($idVar)) {
330 3
                    continue;
331
                }
332
333 10
                $field = $selectExpression->fieldIdentificationVariable;
334
335 10
                if ($field === null) {
336
                    // No need to add this select, as we're already fetching the whole object.
337 10
                    unset($selects[$idVar]);
338
                } else {
339
                    unset($selects[$idVar][$field]);
340
                }
341
            }
342
        }
343
344
        // Add select items which were not excluded to the AST's select clause.
345 10
        foreach ($selects as $idVar => $fields) {
346 3
            $selectExpression = new SelectExpression(new PartialObjectExpression($idVar, array_keys($fields)), null, true);
347
348 3
            $AST->selectClause->selectExpressions[] = $selectExpression;
349
        }
350 10
    }
351
352
    /**
353
     * Generates new SQL for statements with an order by clause
354
     *
355
     * @param mixed[][] $sqlIdentifier
356
     */
357 12
    private function preserveSqlOrdering(
358
        array $sqlIdentifier,
359
        string $innerSql,
360
        string $sql,
361
        ?OrderByClause $orderByClause
362
    ) : string {
363
        // If the sql statement has an order by clause, we need to wrap it in a new select distinct statement
364 12
        if (! $orderByClause) {
365 2
            return $sql;
366
        }
367
368
        // now only select distinct identifier
369 10
        return sprintf(
370 10
            'SELECT DISTINCT %s FROM (%s) dctrn_result',
371 10
            implode(', ', $sqlIdentifier),
372 10
            $this->recreateInnerSql($orderByClause, $sqlIdentifier, $innerSql)
373
        );
374
    }
375
376
    /**
377
     * Generates a new SQL statement for the inner query to keep the correct sorting
378
     *
379
     * @param mixed[][] $identifiers
380
     */
381 10
    private function recreateInnerSql(
382
        OrderByClause $orderByClause,
383
        array $identifiers,
384
        string $innerSql
385
    ) : string {
386 10
        [$searchPatterns, $replacements] = $this->generateSqlAliasReplacements();
387
388 10
        $orderByItems = [];
389
390 10
        foreach ($orderByClause->orderByItems as $orderByItem) {
391
            // Walk order by item to get string representation of it and
392
            // replace path expressions in the order by clause with their column alias
393 10
            $orderByItemString = preg_replace(
394 10
                $searchPatterns,
395 10
                $replacements,
396 10
                $this->walkOrderByItem($orderByItem)
397
            );
398
399 10
            $orderByItems[] = $orderByItemString;
400 10
            $identifier     = substr($orderByItemString, 0, strrpos($orderByItemString, ' '));
401
402 10
            if (! in_array($identifier, $identifiers, true)) {
403 8
                $identifiers[] = $identifier;
404
            }
405
        }
406
407 10
        return $sql = sprintf(
0 ignored issues
show
Unused Code introduced by
The assignment to $sql is dead and can be removed.
Loading history...
408 10
            'SELECT DISTINCT %s FROM (%s) dctrn_result_inner ORDER BY %s',
409 10
            implode(', ', $identifiers),
410 10
            $innerSql,
411 10
            implode(', ', $orderByItems)
412
        );
413
    }
414
415
    /**
416
     * @return string[][]
417
     */
418 10
    private function generateSqlAliasReplacements() : array
419
    {
420 10
        $platform       = $this->em->getConnection()->getDatabasePlatform();
421 10
        $searchPatterns = $replacements = [];
422
423
        // Generate search patterns for each field's path expression in the order by clause
424 10
        foreach ($this->rsm->fieldMappings as $fieldAlias => $fieldName) {
425 10
            $dqlAliasForFieldAlias = $this->rsm->columnOwnerMap[$fieldAlias];
426 10
            $class                 = $this->queryComponents[$dqlAliasForFieldAlias]['metadata'];
427 10
            $property              = $class->getProperty($fieldName);
428
429
            // If the field is from a joined child table, we won't be ordering on it.
430 10
            if ($property === null) {
431
                continue;
432
            }
433
434
            // Get the SQL table alias for the entity and field and the column name as will appear in the select list
435 10
            $tableAlias = $this->getSQLTableAlias($property->getTableName(), $dqlAliasForFieldAlias);
436 10
            $columnName = $platform->quoteIdentifier($property->getColumnName());
437
438
            // Compose search and replace patterns
439 10
            $searchPatterns[] = sprintf(self::ORDER_BY_PATH_EXPRESSION, $tableAlias, $columnName);
440 10
            $replacements[]   = $fieldAlias;
441
        }
442
443 10
        return [$searchPatterns, $replacements];
444
    }
445
446
    /**
447
     * getter for $orderByPathExpressions
448
     *
449
     * @return PathExpression[]
450
     */
451 10
    public function getOrderByPathExpressions()
452
    {
453 10
        return $this->orderByPathExpressions;
454
    }
455
456
    /**
457
     * @return string
458
     *
459
     * @throws OptimisticLockException
460
     * @throws QueryException
461
     */
462 25
    private function getInnerSQL(SelectStatement $AST)
463
    {
464
        // Set every select expression as visible(hidden = false) to
465
        // make $AST have scalar mappings properly - this is relevant for referencing selected
466
        // fields from outside the subquery, for example in the ORDER BY segment
467 25
        $hiddens = [];
468
469 25
        foreach ($AST->selectClause->selectExpressions as $idx => $expr) {
470 25
            $hiddens[$idx]                   = $expr->hiddenAliasResultVariable;
471 25
            $expr->hiddenAliasResultVariable = false;
472
        }
473
474 25
        $innerSql = parent::walkSelectStatement($AST);
475
476
        // Restore hiddens
477 25
        foreach ($AST->selectClause->selectExpressions as $idx => $expr) {
478 25
            $expr->hiddenAliasResultVariable = $hiddens[$idx];
479
        }
480
481 25
        return $innerSql;
482
    }
483
484
    /**
485
     * @return mixed[][]
486
     */
487 25
    private function getSQLIdentifier(SelectStatement $AST)
488
    {
489
        // Find out the SQL alias of the identifier column of the root entity.
490
        // It may be possible to make this work with multiple root entities but that
491
        // would probably require issuing multiple queries or doing a UNION SELECT.
492
        // So for now, it's not supported.
493
494
        // Get the root entity and alias from the AST fromClause.
495 25
        $from = $AST->fromClause->identificationVariableDeclarations;
496
497 25
        if (count($from) !== 1) {
498
            throw new RuntimeException('Cannot count query which selects two FROM components, cannot make distinction');
499
        }
500
501 25
        $fromRoot       = reset($from);
502 25
        $rootAlias      = $fromRoot->rangeVariableDeclaration->aliasIdentificationVariable;
503 25
        $rootClass      = $this->queryComponents[$rootAlias]['metadata'];
504 25
        $rootIdentifier = $rootClass->identifier;
505
506
        // For every identifier, find out the SQL alias by combing through the ResultSetMapping
507 25
        $sqlIdentifier = [];
508
509 25
        foreach ($rootIdentifier as $identifier) {
510 25
            $property = $rootClass->getProperty($identifier);
511
512 25
            if ($property instanceof FieldMetadata) {
513 25
                foreach (array_keys($this->rsm->fieldMappings, $identifier, true) as $alias) {
514 25
                    if ($this->rsm->columnOwnerMap[$alias] === $rootAlias) {
515 25
                        $sqlIdentifier[$identifier] = [
516 25
                            'type'  => $property->getType(),
517 25
                            'alias' => $alias,
518
                        ];
519
                    }
520
                }
521
            } elseif ($property instanceof AssociationMetadata) {
522
                $joinColumns = $property->getJoinColumns();
0 ignored issues
show
Bug introduced by
The method getJoinColumns() does not exist on Doctrine\ORM\Mapping\AssociationMetadata. It seems like you code against a sub-type of Doctrine\ORM\Mapping\AssociationMetadata such as Doctrine\ORM\Mapping\ToOneAssociationMetadata. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

522
                /** @scrutinizer ignore-call */ 
523
                $joinColumns = $property->getJoinColumns();
Loading history...
523
                $joinColumn  = reset($joinColumns);
524
525
                foreach (array_keys($this->rsm->metaMappings, $joinColumn->getColumnName(), true) as $alias) {
526
                    if ($this->rsm->columnOwnerMap[$alias] === $rootAlias) {
527
                        $sqlIdentifier[$identifier] = [
528
                            'type'  => $this->rsm->typeMappings[$alias],
529
                            'alias' => $alias,
530
                        ];
531
                    }
532
                }
533
            }
534
        }
535
536 25
        if (count($sqlIdentifier) === 0) {
537
            throw new RuntimeException('The Paginator does not support Queries which only yield ScalarResults.');
538
        }
539
540 25
        if (count($rootIdentifier) !== count($sqlIdentifier)) {
541
            throw new RuntimeException(sprintf(
542
                'Not all identifier properties can be found in the ResultSetMapping: %s',
543
                implode(', ', array_diff($rootIdentifier, array_keys($sqlIdentifier)))
544
            ));
545
        }
546
547 25
        return $sqlIdentifier;
548
    }
549
550
    /**
551
     * {@inheritdoc}
552
     */
553 20
    public function walkPathExpression($pathExpr)
554
    {
555 20
        if (! $this->inSubSelect && ! $this->platformSupportsRowNumber() && ! in_array($pathExpr, $this->orderByPathExpressions, true)) {
556 8
            $this->orderByPathExpressions[] = $pathExpr;
557
        }
558
559 20
        return parent::walkPathExpression($pathExpr);
560
    }
561
562
    /**
563
     * {@inheritdoc}
564
     */
565 5
    public function walkSubSelect($subselect)
566
    {
567 5
        $this->inSubSelect = true;
568
569 5
        $sql = parent::walkSubselect($subselect);
570
571 5
        $this->inSubSelect = false;
572
573 5
        return $sql;
574
    }
575
}
576