Paginator.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. <?php
  2. /**
  3. * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
  4. * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  5. *
  6. * Licensed under The MIT License
  7. * For full copyright and license information, please see the LICENSE.txt
  8. * Redistributions of files must retain the above copyright notice.
  9. *
  10. * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  11. * @link http://cakephp.org CakePHP(tm) Project
  12. * @since 3.5.0
  13. * @license http://www.opensource.org/licenses/mit-license.php MIT License
  14. */
  15. namespace Cake\Datasource;
  16. use Cake\Core\InstanceConfigTrait;
  17. use Cake\Datasource\Exception\PageOutOfBoundsException;
  18. /**
  19. * This class is used to handle automatic model data pagination.
  20. */
  21. class Paginator implements PaginatorInterface
  22. {
  23. use InstanceConfigTrait;
  24. /**
  25. * Default pagination settings.
  26. *
  27. * When calling paginate() these settings will be merged with the configuration
  28. * you provide.
  29. *
  30. * - `maxLimit` - The maximum limit users can choose to view. Defaults to 100
  31. * - `limit` - The initial number of items per page. Defaults to 20.
  32. * - `page` - The starting page, defaults to 1.
  33. * - `whitelist` - A list of parameters users are allowed to set using request
  34. * parameters. Modifying this list will allow users to have more influence
  35. * over pagination, be careful with what you permit.
  36. *
  37. * @var array
  38. */
  39. protected $_defaultConfig = [
  40. 'page' => 1,
  41. 'limit' => 20,
  42. 'maxLimit' => 100,
  43. 'whitelist' => ['limit', 'sort', 'page', 'direction']
  44. ];
  45. /**
  46. * Paging params after pagination operation is done.
  47. *
  48. * @var array
  49. */
  50. protected $_pagingParams = [];
  51. /**
  52. * Handles automatic pagination of model records.
  53. *
  54. * ### Configuring pagination
  55. *
  56. * When calling `paginate()` you can use the $settings parameter to pass in
  57. * pagination settings. These settings are used to build the queries made
  58. * and control other pagination settings.
  59. *
  60. * If your settings contain a key with the current table's alias. The data
  61. * inside that key will be used. Otherwise the top level configuration will
  62. * be used.
  63. *
  64. * ```
  65. * $settings = [
  66. * 'limit' => 20,
  67. * 'maxLimit' => 100
  68. * ];
  69. * $results = $paginator->paginate($table, $settings);
  70. * ```
  71. *
  72. * The above settings will be used to paginate any repository. You can configure
  73. * repository specific settings by keying the settings with the repository alias.
  74. *
  75. * ```
  76. * $settings = [
  77. * 'Articles' => [
  78. * 'limit' => 20,
  79. * 'maxLimit' => 100
  80. * ],
  81. * 'Comments' => [ ... ]
  82. * ];
  83. * $results = $paginator->paginate($table, $settings);
  84. * ```
  85. *
  86. * This would allow you to have different pagination settings for
  87. * `Articles` and `Comments` repositories.
  88. *
  89. * ### Controlling sort fields
  90. *
  91. * By default CakePHP will automatically allow sorting on any column on the
  92. * repository object being paginated. Often times you will want to allow
  93. * sorting on either associated columns or calculated fields. In these cases
  94. * you will need to define a whitelist of all the columns you wish to allow
  95. * sorting on. You can define the whitelist in the `$settings` parameter:
  96. *
  97. * ```
  98. * $settings = [
  99. * 'Articles' => [
  100. * 'finder' => 'custom',
  101. * 'sortWhitelist' => ['title', 'author_id', 'comment_count'],
  102. * ]
  103. * ];
  104. * ```
  105. *
  106. * Passing an empty array as whitelist disallows sorting altogether.
  107. *
  108. * ### Paginating with custom finders
  109. *
  110. * You can paginate with any find type defined on your table using the
  111. * `finder` option.
  112. *
  113. * ```
  114. * $settings = [
  115. * 'Articles' => [
  116. * 'finder' => 'popular'
  117. * ]
  118. * ];
  119. * $results = $paginator->paginate($table, $settings);
  120. * ```
  121. *
  122. * Would paginate using the `find('popular')` method.
  123. *
  124. * You can also pass an already created instance of a query to this method:
  125. *
  126. * ```
  127. * $query = $this->Articles->find('popular')->matching('Tags', function ($q) {
  128. * return $q->where(['name' => 'CakePHP'])
  129. * });
  130. * $results = $paginator->paginate($query);
  131. * ```
  132. *
  133. * ### Scoping Request parameters
  134. *
  135. * By using request parameter scopes you can paginate multiple queries in
  136. * the same controller action:
  137. *
  138. * ```
  139. * $articles = $paginator->paginate($articlesQuery, ['scope' => 'articles']);
  140. * $tags = $paginator->paginate($tagsQuery, ['scope' => 'tags']);
  141. * ```
  142. *
  143. * Each of the above queries will use different query string parameter sets
  144. * for pagination data. An example URL paginating both results would be:
  145. *
  146. * ```
  147. * /dashboard?articles[page]=1&tags[page]=2
  148. * ```
  149. *
  150. * @param \Cake\Datasource\RepositoryInterface|\Cake\Datasource\QueryInterface $object The table or query to paginate.
  151. * @param array $params Request params
  152. * @param array $settings The settings/configuration used for pagination.
  153. * @return \Cake\Datasource\ResultSetInterface Query results
  154. * @throws \Cake\Datasource\Exception\PageOutOfBoundsException
  155. */
  156. public function paginate($object, array $params = [], array $settings = [])
  157. {
  158. $query = null;
  159. if ($object instanceof QueryInterface) {
  160. $query = $object;
  161. $object = $query->getRepository();
  162. }
  163. $alias = $object->getAlias();
  164. $defaults = $this->getDefaults($alias, $settings);
  165. $options = $this->mergeOptions($params, $defaults);
  166. $options = $this->validateSort($object, $options);
  167. $options = $this->checkLimit($options);
  168. $options += ['page' => 1, 'scope' => null];
  169. $options['page'] = (int)$options['page'] < 1 ? 1 : (int)$options['page'];
  170. list($finder, $options) = $this->_extractFinder($options);
  171. if (empty($query)) {
  172. $query = $object->find($finder, $options);
  173. } else {
  174. $query->applyOptions($options);
  175. }
  176. $cleanQuery = clone $query;
  177. $results = $query->all();
  178. $numResults = count($results);
  179. $count = $cleanQuery->count();
  180. $page = $options['page'];
  181. $limit = $options['limit'];
  182. $pageCount = max((int)ceil($count / $limit), 1);
  183. $requestedPage = $page;
  184. $page = min($page, $pageCount);
  185. $order = (array)$options['order'];
  186. $sortDefault = $directionDefault = false;
  187. if (!empty($defaults['order']) && count($defaults['order']) === 1) {
  188. $sortDefault = key($defaults['order']);
  189. $directionDefault = current($defaults['order']);
  190. }
  191. $start = 0;
  192. if ($count >= 1) {
  193. $start = (($page - 1) * $limit) + 1;
  194. }
  195. $end = $start + $limit - 1;
  196. if ($count < $end) {
  197. $end = $count;
  198. }
  199. $paging = [
  200. 'finder' => $finder,
  201. 'page' => $page,
  202. 'current' => $numResults,
  203. 'count' => $count,
  204. 'perPage' => $limit,
  205. 'start' => $start,
  206. 'end' => $end,
  207. 'prevPage' => $page > 1,
  208. 'nextPage' => $count > ($page * $limit),
  209. 'pageCount' => $pageCount,
  210. 'sort' => $options['sort'],
  211. 'direction' => isset($options['sort']) ? current($order) : null,
  212. 'limit' => $defaults['limit'] != $limit ? $limit : null,
  213. 'sortDefault' => $sortDefault,
  214. 'directionDefault' => $directionDefault,
  215. 'scope' => $options['scope'],
  216. 'completeSort' => $order,
  217. ];
  218. $this->_pagingParams = [$alias => $paging];
  219. if ($requestedPage > $page) {
  220. throw new PageOutOfBoundsException([
  221. 'requestedPage' => $requestedPage,
  222. 'pagingParams' => $this->_pagingParams
  223. ]);
  224. }
  225. return $results;
  226. }
  227. /**
  228. * Extracts the finder name and options out of the provided pagination options.
  229. *
  230. * @param array $options the pagination options.
  231. * @return array An array containing in the first position the finder name
  232. * and in the second the options to be passed to it.
  233. */
  234. protected function _extractFinder($options)
  235. {
  236. $type = !empty($options['finder']) ? $options['finder'] : 'all';
  237. unset($options['finder'], $options['maxLimit']);
  238. if (is_array($type)) {
  239. $options = (array)current($type) + $options;
  240. $type = key($type);
  241. }
  242. return [$type, $options];
  243. }
  244. /**
  245. * Get paging params after pagination operation.
  246. *
  247. * @return array
  248. */
  249. public function getPagingParams()
  250. {
  251. return $this->_pagingParams;
  252. }
  253. /**
  254. * Merges the various options that Paginator uses.
  255. * Pulls settings together from the following places:
  256. *
  257. * - General pagination settings
  258. * - Model specific settings.
  259. * - Request parameters
  260. *
  261. * The result of this method is the aggregate of all the option sets
  262. * combined together. You can change config value `whitelist` to modify
  263. * which options/values can be set using request parameters.
  264. *
  265. * @param array $params Request params.
  266. * @param array $settings The settings to merge with the request data.
  267. * @return array Array of merged options.
  268. */
  269. public function mergeOptions($params, $settings)
  270. {
  271. if (!empty($settings['scope'])) {
  272. $scope = $settings['scope'];
  273. $params = !empty($params[$scope]) ? (array)$params[$scope] : [];
  274. }
  275. $params = array_intersect_key($params, array_flip($this->getConfig('whitelist')));
  276. return array_merge($settings, $params);
  277. }
  278. /**
  279. * Get the settings for a $model. If there are no settings for a specific
  280. * repository, the general settings will be used.
  281. *
  282. * @param string $alias Model name to get settings for.
  283. * @param array $settings The settings which is used for combining.
  284. * @return array An array of pagination settings for a model,
  285. * or the general settings.
  286. */
  287. public function getDefaults($alias, $settings)
  288. {
  289. if (isset($settings[$alias])) {
  290. $settings = $settings[$alias];
  291. }
  292. $defaults = $this->getConfig();
  293. $maxLimit = isset($settings['maxLimit']) ? $settings['maxLimit'] : $defaults['maxLimit'];
  294. $limit = isset($settings['limit']) ? $settings['limit'] : $defaults['limit'];
  295. if ($limit > $maxLimit) {
  296. $limit = $maxLimit;
  297. }
  298. $settings['maxLimit'] = $maxLimit;
  299. $settings['limit'] = $limit;
  300. return $settings + $defaults;
  301. }
  302. /**
  303. * Validate that the desired sorting can be performed on the $object.
  304. *
  305. * Only fields or virtualFields can be sorted on. The direction param will
  306. * also be sanitized. Lastly sort + direction keys will be converted into
  307. * the model friendly order key.
  308. *
  309. * You can use the whitelist parameter to control which columns/fields are
  310. * available for sorting via URL parameters. This helps prevent users from ordering large
  311. * result sets on un-indexed values.
  312. *
  313. * If you need to sort on associated columns or synthetic properties you
  314. * will need to use a whitelist.
  315. *
  316. * Any columns listed in the sort whitelist will be implicitly trusted.
  317. * You can use this to sort on synthetic columns, or columns added in custom
  318. * find operations that may not exist in the schema.
  319. *
  320. * The default order options provided to paginate() will be merged with the user's
  321. * requested sorting field/direction.
  322. *
  323. * @param \Cake\Datasource\RepositoryInterface $object Repository object.
  324. * @param array $options The pagination options being used for this request.
  325. * @return array An array of options with sort + direction removed and
  326. * replaced with order if possible.
  327. */
  328. public function validateSort(RepositoryInterface $object, array $options)
  329. {
  330. if (isset($options['sort'])) {
  331. $direction = null;
  332. if (isset($options['direction'])) {
  333. $direction = strtolower($options['direction']);
  334. }
  335. if (!in_array($direction, ['asc', 'desc'])) {
  336. $direction = 'asc';
  337. }
  338. $order = (isset($options['order']) && is_array($options['order'])) ? $options['order'] : [];
  339. if ($order && $options['sort'] && strpos($options['sort'], '.') === false) {
  340. $order = $this->_removeAliases($order, $object->getAlias());
  341. }
  342. $options['order'] = [$options['sort'] => $direction] + $order;
  343. } else {
  344. $options['sort'] = null;
  345. }
  346. unset($options['direction']);
  347. if (empty($options['order'])) {
  348. $options['order'] = [];
  349. }
  350. if (!is_array($options['order'])) {
  351. return $options;
  352. }
  353. $inWhitelist = false;
  354. if (isset($options['sortWhitelist'])) {
  355. $field = key($options['order']);
  356. $inWhitelist = in_array($field, $options['sortWhitelist'], true);
  357. if (!$inWhitelist) {
  358. $options['order'] = [];
  359. $options['sort'] = null;
  360. return $options;
  361. }
  362. }
  363. if ($options['sort'] === null
  364. && count($options['order']) === 1
  365. && !is_numeric(key($options['order']))
  366. ) {
  367. $options['sort'] = key($options['order']);
  368. }
  369. $options['order'] = $this->_prefix($object, $options['order'], $inWhitelist);
  370. return $options;
  371. }
  372. /**
  373. * Remove alias if needed.
  374. *
  375. * @param array $fields Current fields
  376. * @param string $model Current model alias
  377. * @return array $fields Unaliased fields where applicable
  378. */
  379. protected function _removeAliases($fields, $model)
  380. {
  381. $result = [];
  382. foreach ($fields as $field => $sort) {
  383. if (strpos($field, '.') === false) {
  384. $result[$field] = $sort;
  385. continue;
  386. }
  387. list ($alias, $currentField) = explode('.', $field);
  388. if ($alias === $model) {
  389. $result[$currentField] = $sort;
  390. continue;
  391. }
  392. $result[$field] = $sort;
  393. }
  394. return $result;
  395. }
  396. /**
  397. * Prefixes the field with the table alias if possible.
  398. *
  399. * @param \Cake\Datasource\RepositoryInterface $object Repository object.
  400. * @param array $order Order array.
  401. * @param bool $whitelisted Whether or not the field was whitelisted.
  402. * @return array Final order array.
  403. */
  404. protected function _prefix(RepositoryInterface $object, $order, $whitelisted = false)
  405. {
  406. $tableAlias = $object->getAlias();
  407. $tableOrder = [];
  408. foreach ($order as $key => $value) {
  409. if (is_numeric($key)) {
  410. $tableOrder[] = $value;
  411. continue;
  412. }
  413. $field = $key;
  414. $alias = $tableAlias;
  415. if (strpos($key, '.') !== false) {
  416. list($alias, $field) = explode('.', $key);
  417. }
  418. $correctAlias = ($tableAlias === $alias);
  419. if ($correctAlias && $whitelisted) {
  420. // Disambiguate fields in schema. As id is quite common.
  421. if ($object->hasField($field)) {
  422. $field = $alias . '.' . $field;
  423. }
  424. $tableOrder[$field] = $value;
  425. } elseif ($correctAlias && $object->hasField($field)) {
  426. $tableOrder[$tableAlias . '.' . $field] = $value;
  427. } elseif (!$correctAlias && $whitelisted) {
  428. $tableOrder[$alias . '.' . $field] = $value;
  429. }
  430. }
  431. return $tableOrder;
  432. }
  433. /**
  434. * Check the limit parameter and ensure it's within the maxLimit bounds.
  435. *
  436. * @param array $options An array of options with a limit key to be checked.
  437. * @return array An array of options for pagination.
  438. */
  439. public function checkLimit(array $options)
  440. {
  441. $options['limit'] = (int)$options['limit'];
  442. if (empty($options['limit']) || $options['limit'] < 1) {
  443. $options['limit'] = 1;
  444. }
  445. $options['limit'] = max(min($options['limit'], $options['maxLimit']), 1);
  446. return $options;
  447. }
  448. }