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