vendor/doctrine/orm/src/Tools/Pagination/LimitSubqueryOutputWalker.php line 288

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