Failed Conditions
Pull Request — master (#7242)
by Gabriel
08:46
created

generateSqlAliasReplacements()   B

Complexity

Conditions 3
Paths 3

Size

Total Lines 26
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 3.0032

Importance

Changes 0
Metric Value
cc 3
eloc 13
nc 3
nop 0
dl 0
loc 26
ccs 13
cts 14
cp 0.9286
crap 3.0032
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\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 function array_diff;
29
use function array_keys;
30
use function array_map;
31
use function count;
32
use function implode;
33
use function in_array;
34
use function is_string;
35
use function method_exists;
36
use function preg_replace;
37
use function reset;
38
use function sprintf;
39
use function strrpos;
40
use function substr;
41
42
/**
43
 * Wraps the query in order to select root entity IDs for pagination.
44
 *
45
 * Given a DQL like `SELECT u FROM User u` it will generate an SQL query like:
46
 * SELECT DISTINCT <id> FROM (<original SQL>) LIMIT x OFFSET y
47
 *
48
 * Works with composite keys but cannot deal with queries that have multiple
49
 * root entities (e.g. `SELECT f, b from Foo, Bar`)
50
 */
51
class LimitSubqueryOutputWalker extends SqlWalker
52
{
53
    private const ORDER_BY_PATH_EXPRESSION = '/(?<![a-z0-9_])%s\.%s(?![a-z0-9_])/i';
54
55
    /** @var AbstractPlatform */
56
    private $platform;
57
58
    /** @var ResultSetMapping */
59
    private $rsm;
60
61
    /** @var mixed[][] */
62
    private $queryComponents;
63
64
    /** @var int */
65
    private $firstResult;
66
67
    /** @var int */
68
    private $maxResults;
69
70
    /** @var EntityManagerInterface */
71
    private $em;
72
73
    /** @var PathExpression[] */
74
    private $orderByPathExpressions = [];
75
76
    /**
77
     * @var bool We don't want to add path expressions from sub-selects into the select clause of the containing query.
78
     *           This state flag simply keeps track on whether we are walking on a subquery or not
79
     */
80
    private $inSubSelect = false;
81
82
    /**
83
     * Stores various parameters that are otherwise unavailable
84
     * because Doctrine\ORM\Query\SqlWalker keeps everything private without
85
     * accessors.
86
     *
87
     * @param Query        $query
88
     * @param ParserResult $parserResult
89
     * @param mixed[][]    $queryComponents
90
     */
91 25
    public function __construct($query, $parserResult, array $queryComponents)
92
    {
93 25
        $this->platform        = $query->getEntityManager()->getConnection()->getDatabasePlatform();
94 25
        $this->rsm             = $parserResult->getResultSetMapping();
95 25
        $this->queryComponents = $queryComponents;
96
97
        // Reset limit and offset
98 25
        $this->firstResult = $query->getFirstResult();
99 25
        $this->maxResults  = $query->getMaxResults();
100
101
        $query
102 25
            ->setFirstResult(null)
103 25
            ->setMaxResults(null)
104
        ;
105
106 25
        $this->em = $query->getEntityManager();
107
108 25
        parent::__construct($query, $parserResult, $queryComponents);
109 25
    }
110
111
    /**
112
     * Check if the platform supports the ROW_NUMBER window function.
113
     *
114
     * @return bool
115
     */
116 25
    private function platformSupportsRowNumber()
117
    {
118 25
        return $this->platform instanceof PostgreSqlPlatform
119 18
            || $this->platform instanceof SQLServerPlatform
120 18
            || $this->platform instanceof OraclePlatform
121 12
            || $this->platform instanceof SQLAnywherePlatform
122 12
            || $this->platform instanceof DB2Platform
123 12
            || (method_exists($this->platform, 'supportsRowNumberFunction')
124 25
                && $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
                continue;
144
            }
145
146 7
            $orderByItem->expression = $selectAliasToExpressionMap[$orderByItem->expression];
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(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);
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(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
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
     */
295 10
    private function addMissingItemsFromOrderByToSelect(SelectStatement $AST)
296
    {
297 10
        $this->orderByPathExpressions = [];
298
299
        // We need to do this in another walker because otherwise we'll end up
300
        // polluting the state of this one.
301 10
        $walker = clone $this;
302
303
        // This will populate $orderByPathExpressions via
304
        // LimitSubqueryOutputWalker::walkPathExpression, which will be called
305
        // as the select statement is walked. We'll end up with an array of all
306
        // path expressions referenced in the query.
307 10
        $walker->walkSelectStatementWithoutRowNumber($AST, false);
308 10
        $orderByPathExpressions = $walker->getOrderByPathExpressions();
309
310
        // Get a map of referenced identifiers to field names.
311 10
        $selects = [];
312
313 10
        foreach ($orderByPathExpressions as $pathExpression) {
314 7
            $idVar = $pathExpression->identificationVariable;
315 7
            $field = $pathExpression->field;
316
317 7
            if (! isset($selects[$idVar])) {
318 7
                $selects[$idVar] = [];
319
            }
320
321 7
            $selects[$idVar][$field] = true;
322
        }
323
324
        // Loop the select clause of the AST and exclude items from $select
325
        // that are already being selected in the query.
326 10
        foreach ($AST->selectClause->selectExpressions as $selectExpression) {
327 10
            if (! ($selectExpression instanceof SelectExpression)) {
328
                continue;
329
            }
330
331 10
            $idVar = $selectExpression->expression;
332
333 10
            if (! is_string($idVar)) {
334 3
                continue;
335
            }
336
337 10
            $field = $selectExpression->fieldIdentificationVariable;
338
339 10
            if ($field === null) {
340
                // No need to add this select, as we're already fetching the whole object.
341 10
                unset($selects[$idVar]);
342
            } else {
343 10
                unset($selects[$idVar][$field]);
344
            }
345
        }
346
347
        // Add select items which were not excluded to the AST's select clause.
348 10
        foreach ($selects as $idVar => $fields) {
349 3
            $selectExpression = new SelectExpression(new PartialObjectExpression($idVar, array_keys($fields)), null, true);
350
351 3
            $AST->selectClause->selectExpressions[] = $selectExpression;
352
        }
353 10
    }
354
355
    /**
356
     * Generates new SQL for statements with an order by clause
357
     *
358
     * @param mixed[][] $sqlIdentifier
359
     */
360 12
    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 12
        if (! $orderByClause) {
368 2
            return $sql;
369
        }
370
371
        // now only select distinct identifier
372 10
        return sprintf(
373 10
            'SELECT DISTINCT %s FROM (%s) dctrn_result',
374 10
            implode(', ', $sqlIdentifier),
375 10
            $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 mixed[][] $identifiers
383
     */
384 10
    private function recreateInnerSql(
385
        OrderByClause $orderByClause,
386
        array $identifiers,
387
        string $innerSql
388
    ) : string {
389 10
        [$searchPatterns, $replacements] = $this->generateSqlAliasReplacements();
390
391 10
        $orderByItems = [];
392
393 10
        foreach ($orderByClause->orderByItems as $orderByItem) {
394
            // Walk order by item to get string representation of it and
395
            // replace path expressions in the order by clause with their column alias
396 10
            $orderByItemString = preg_replace(
397 10
                $searchPatterns,
398 10
                $replacements,
399 10
                $this->walkOrderByItem($orderByItem)
400
            );
401
402 10
            $orderByItems[] = $orderByItemString;
403 10
            $identifier     = substr($orderByItemString, 0, strrpos($orderByItemString, ' '));
404
405 10
            if (in_array($identifier, $identifiers, true)) {
406 2
                continue;
407
            }
408
409 8
            $identifiers[] = $identifier;
410
        }
411
412 10
        return $sql = sprintf(
0 ignored issues
show
Unused Code introduced by
The assignment to $sql is dead and can be removed.
Loading history...
413 10
            'SELECT DISTINCT %s FROM (%s) dctrn_result_inner ORDER BY %s',
414 10
            implode(', ', $identifiers),
415 10
            $innerSql,
416 10
            implode(', ', $orderByItems)
417
        );
418
    }
419
420
    /**
421
     * @return string[][]
422
     */
423 10
    private function generateSqlAliasReplacements() : array
424
    {
425 10
        $platform       = $this->em->getConnection()->getDatabasePlatform();
426 10
        $searchPatterns = $replacements = [];
427
428
        // Generate search patterns for each field's path expression in the order by clause
429 10
        foreach ($this->rsm->fieldMappings as $fieldAlias => $fieldName) {
430 10
            $dqlAliasForFieldAlias = $this->rsm->columnOwnerMap[$fieldAlias];
431 10
            $class                 = $this->queryComponents[$dqlAliasForFieldAlias]['metadata'];
432 10
            $property              = $class->getProperty($fieldName);
433
434
            // If the field is from a joined child table, we won't be ordering on it.
435 10
            if ($property === null) {
436
                continue;
437
            }
438
439
            // Get the SQL table alias for the entity and field and the column name as will appear in the select list
440 10
            $tableAlias = $this->getSQLTableAlias($property->getTableName(), $dqlAliasForFieldAlias);
441 10
            $columnName = $platform->quoteIdentifier($property->getColumnName());
442
443
            // Compose search and replace patterns
444 10
            $searchPatterns[] = sprintf(self::ORDER_BY_PATH_EXPRESSION, $tableAlias, $columnName);
445 10
            $replacements[]   = $fieldAlias;
446
        }
447
448 10
        return [$searchPatterns, $replacements];
449
    }
450
451
    /**
452
     * getter for $orderByPathExpressions
453
     *
454
     * @return PathExpression[]
455
     */
456 10
    public function getOrderByPathExpressions()
457
    {
458 10
        return $this->orderByPathExpressions;
459
    }
460
461
    /**
462
     * @return string
463
     *
464
     * @throws OptimisticLockException
465
     * @throws QueryException
466
     */
467 25
    private function getInnerSQL(SelectStatement $AST)
468
    {
469
        // Set every select expression as visible(hidden = false) to
470
        // make $AST have scalar mappings properly - this is relevant for referencing selected
471
        // fields from outside the subquery, for example in the ORDER BY segment
472 25
        $hiddens = [];
473
474 25
        foreach ($AST->selectClause->selectExpressions as $idx => $expr) {
475 25
            $hiddens[$idx]                   = $expr->hiddenAliasResultVariable;
476 25
            $expr->hiddenAliasResultVariable = false;
477
        }
478
479 25
        $innerSql = parent::walkSelectStatement($AST);
480
481
        // Restore hiddens
482 25
        foreach ($AST->selectClause->selectExpressions as $idx => $expr) {
483 25
            $expr->hiddenAliasResultVariable = $hiddens[$idx];
484
        }
485
486 25
        return $innerSql;
487
    }
488
489
    /**
490
     * @return mixed[][]
491
     */
492 25
    private function getSQLIdentifier(SelectStatement $AST)
493
    {
494
        // Find out the SQL alias of the identifier column of the root entity.
495
        // It may be possible to make this work with multiple root entities but that
496
        // would probably require issuing multiple queries or doing a UNION SELECT.
497
        // So for now, it's not supported.
498
499
        // Get the root entity and alias from the AST fromClause.
500 25
        $from = $AST->fromClause->identificationVariableDeclarations;
501
502 25
        if (count($from) !== 1) {
503
            throw new \RuntimeException('Cannot count query which selects two FROM components, cannot make distinction');
504
        }
505
506 25
        $fromRoot       = reset($from);
507 25
        $rootAlias      = $fromRoot->rangeVariableDeclaration->aliasIdentificationVariable;
508 25
        $rootClass      = $this->queryComponents[$rootAlias]['metadata'];
509 25
        $rootIdentifier = $rootClass->identifier;
510
511
        // For every identifier, find out the SQL alias by combing through the ResultSetMapping
512 25
        $sqlIdentifier = [];
513
514 25
        foreach ($rootIdentifier as $identifier) {
515 25
            $property = $rootClass->getProperty($identifier);
516
517 25
            if ($property instanceof FieldMetadata) {
518 25
                foreach (array_keys($this->rsm->fieldMappings, $identifier, true) as $alias) {
519 25
                    if ($this->rsm->columnOwnerMap[$alias] !== $rootAlias) {
520 11
                        continue;
521
                    }
522
523 25
                    $sqlIdentifier[$identifier] = [
524 25
                        'type'  => $property->getType(),
525 25
                        'alias' => $alias,
526
                    ];
527
                }
528
            } elseif ($property instanceof AssociationMetadata) {
529
                $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

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