Failed Conditions
Pull Request — develop (#6947)
by Filippo
10:01
created

generateSqlAliasReplacements()   B

Complexity

Conditions 3
Paths 3

Size

Total Lines 25
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 3

Importance

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

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