PageRenderTime 55ms CodeModel.GetById 9ms RepoModel.GetById 1ms app.codeStats 0ms

/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryOutputWalker.php

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