Failed Conditions
Pull Request — master (#6735)
by Matthias
09:54
created

walkSelectStatementWithRowNumber()   A

Complexity

Conditions 5
Paths 16

Size

Total Lines 48
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 25
CRAP Score 5

Importance

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

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