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