<?php
/**
* CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
* @link http://cakephp.org CakePHP(tm) Project
* @since 3.5.0
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace Cake\Datasource;
use Cake\Core\InstanceConfigTrait;
use Cake\Datasource\Exception\PageOutOfBoundsException;
/**
* This class is used to handle automatic model data pagination.
*/
class Paginator implements PaginatorInterface
{
use InstanceConfigTrait;
/**
* Default pagination settings.
*
* When calling paginate() these settings will be merged with the configuration
* you provide.
*
* - `maxLimit` - The maximum limit users can choose to view. Defaults to 100
* - `limit` - The initial number of items per page. Defaults to 20.
* - `page` - The starting page, defaults to 1.
* - `whitelist` - A list of parameters users are allowed to set using request
* parameters. Modifying this list will allow users to have more influence
* over pagination, be careful with what you permit.
*
* @var array
*/
protected $_defaultConfig = [
'page' => 1,
'limit' => 20,
'maxLimit' => 100,
'whitelist' => ['limit', 'sort', 'page', 'direction']
];
/**
* Paging params after pagination operation is done.
*
* @var array
*/
protected $_pagingParams = [];
/**
* Handles automatic pagination of model records.
*
* ### Configuring pagination
*
* When calling `paginate()` you can use the $settings parameter to pass in
* pagination settings. These settings are used to build the queries made
* and control other pagination settings.
*
* If your settings contain a key with the current table's alias. The data
* inside that key will be used. Otherwise the top level configuration will
* be used.
*
* ```
* $settings = [
* 'limit' => 20,
* 'maxLimit' => 100
* ];
* $results = $paginator->paginate($table, $settings);
* ```
*
* The above settings will be used to paginate any repository. You can configure
* repository specific settings by keying the settings with the repository alias.
*
* ```
* $settings = [
* 'Articles' => [
* 'limit' => 20,
* 'maxLimit' => 100
* ],
* 'Comments' => [ ... ]
* ];
* $results = $paginator->paginate($table, $settings);
* ```
*
* This would allow you to have different pagination settings for
* `Articles` and `Comments` repositories.
*
* ### Controlling sort fields
*
* By default CakePHP will automatically allow sorting on any column on the
* repository object being paginated. Often times you will want to allow
* sorting on either associated columns or calculated fields. In these cases
* you will need to define a whitelist of all the columns you wish to allow
* sorting on. You can define the whitelist in the `$settings` parameter:
*
* ```
* $settings = [
* 'Articles' => [
* 'finder' => 'custom',
* 'sortWhitelist' => ['title', 'author_id', 'comment_count'],
* ]
* ];
* ```
*
* Passing an empty array as whitelist disallows sorting altogether.
*
* ### Paginating with custom finders
*
* You can paginate with any find type defined on your table using the
* `finder` option.
*
* ```
* $settings = [
* 'Articles' => [
* 'finder' => 'popular'
* ]
* ];
* $results = $paginator->paginate($table, $settings);
* ```
*
* Would paginate using the `find('popular')` method.
*
* You can also pass an already created instance of a query to this method:
*
* ```
* $query = $this->Articles->find('popular')->matching('Tags', function ($q) {
* return $q->where(['name' => 'CakePHP'])
* });
* $results = $paginator->paginate($query);
* ```
*
* ### Scoping Request parameters
*
* By using request parameter scopes you can paginate multiple queries in
* the same controller action:
*
* ```
* $articles = $paginator->paginate($articlesQuery, ['scope' => 'articles']);
* $tags = $paginator->paginate($tagsQuery, ['scope' => 'tags']);
* ```
*
* Each of the above queries will use different query string parameter sets
* for pagination data. An example URL paginating both results would be:
*
* ```
* /dashboard?articles[page]=1&tags[page]=2
* ```
*
* @param \Cake\Datasource\RepositoryInterface|\Cake\Datasource\QueryInterface $object The table or query to paginate.
* @param array $params Request params
* @param array $settings The settings/configuration used for pagination.
* @return \Cake\Datasource\ResultSetInterface Query results
* @throws \Cake\Datasource\Exception\PageOutOfBoundsException
*/
public function paginate($object, array $params = [], array $settings = [])
{
$query = null;
if ($object instanceof QueryInterface) {
$query = $object;
$object = $query->getRepository();
}
$alias = $object->getAlias();
$defaults = $this->getDefaults($alias, $settings);
$options = $this->mergeOptions($params, $defaults);
$options = $this->validateSort($object, $options);
$options = $this->checkLimit($options);
$options += ['page' => 1, 'scope' => null];
$options['page'] = (int)$options['page'] < 1 ? 1 : (int)$options['page'];
list($finder, $options) = $this->_extractFinder($options);
if (empty($query)) {
$query = $object->find($finder, $options);
} else {
$query->applyOptions($options);
}
$cleanQuery = clone $query;
$results = $query->all();
$numResults = count($results);
$count = $cleanQuery->count();
$page = $options['page'];
$limit = $options['limit'];
$pageCount = max((int)ceil($count / $limit), 1);
$requestedPage = $page;
$page = min($page, $pageCount);
$order = (array)$options['order'];
$sortDefault = $directionDefault = false;
if (!empty($defaults['order']) && count($defaults['order']) === 1) {
$sortDefault = key($defaults['order']);
$directionDefault = current($defaults['order']);
}
$start = 0;
if ($count >= 1) {
$start = (($page - 1) * $limit) + 1;
}
$end = $start + $limit - 1;
if ($count < $end) {
$end = $count;
}
$paging = [
'finder' => $finder,
'page' => $page,
'current' => $numResults,
'count' => $count,
'perPage' => $limit,
'start' => $start,
'end' => $end,
'prevPage' => $page > 1,
'nextPage' => $count > ($page * $limit),
'pageCount' => $pageCount,
'sort' => $options['sort'],
'direction' => isset($options['sort']) ? current($order) : null,
'limit' => $defaults['limit'] != $limit ? $limit : null,
'sortDefault' => $sortDefault,
'directionDefault' => $directionDefault,
'scope' => $options['scope'],
'completeSort' => $order,
];
$this->_pagingParams = [$alias => $paging];
if ($requestedPage > $page) {
throw new PageOutOfBoundsException([
'requestedPage' => $requestedPage,
'pagingParams' => $this->_pagingParams
]);
}
return $results;
}
/**
* Extracts the finder name and options out of the provided pagination options.
*
* @param array $options the pagination options.
* @return array An array containing in the first position the finder name
* and in the second the options to be passed to it.
*/
protected function _extractFinder($options)
{
$type = !empty($options['finder']) ? $options['finder'] : 'all';
unset($options['finder'], $options['maxLimit']);
if (is_array($type)) {
$options = (array)current($type) + $options;
$type = key($type);
}
return [$type, $options];
}
/**
* Get paging params after pagination operation.
*
* @return array
*/
public function getPagingParams()
{
return $this->_pagingParams;
}
/**
* Merges the various options that Paginator uses.
* Pulls settings together from the following places:
*
* - General pagination settings
* - Model specific settings.
* - Request parameters
*
* The result of this method is the aggregate of all the option sets
* combined together. You can change config value `whitelist` to modify
* which options/values can be set using request parameters.
*
* @param array $params Request params.
* @param array $settings The settings to merge with the request data.
* @return array Array of merged options.
*/
public function mergeOptions($params, $settings)
{
if (!empty($settings['scope'])) {
$scope = $settings['scope'];
$params = !empty($params[$scope]) ? (array)$params[$scope] : [];
}
$params = array_intersect_key($params, array_flip($this->getConfig('whitelist')));
return array_merge($settings, $params);
}
/**
* Get the settings for a $model. If there are no settings for a specific
* repository, the general settings will be used.
*
* @param string $alias Model name to get settings for.
* @param array $settings The settings which is used for combining.
* @return array An array of pagination settings for a model,
* or the general settings.
*/
public function getDefaults($alias, $settings)
{
if (isset($settings[$alias])) {
$settings = $settings[$alias];
}
$defaults = $this->getConfig();
$maxLimit = isset($settings['maxLimit']) ? $settings['maxLimit'] : $defaults['maxLimit'];
$limit = isset($settings['limit']) ? $settings['limit'] : $defaults['limit'];
if ($limit > $maxLimit) {
$limit = $maxLimit;
}
$settings['maxLimit'] = $maxLimit;
$settings['limit'] = $limit;
return $settings + $defaults;
}
/**
* Validate that the desired sorting can be performed on the $object.
*
* Only fields or virtualFields can be sorted on. The direction param will
* also be sanitized. Lastly sort + direction keys will be converted into
* the model friendly order key.
*
* You can use the whitelist parameter to control which columns/fields are
* available for sorting via URL parameters. This helps prevent users from ordering large
* result sets on un-indexed values.
*
* If you need to sort on associated columns or synthetic properties you
* will need to use a whitelist.
*
* Any columns listed in the sort whitelist will be implicitly trusted.
* You can use this to sort on synthetic columns, or columns added in custom
* find operations that may not exist in the schema.
*
* The default order options provided to paginate() will be merged with the user's
* requested sorting field/direction.
*
* @param \Cake\Datasource\RepositoryInterface $object Repository object.
* @param array $options The pagination options being used for this request.
* @return array An array of options with sort + direction removed and
* replaced with order if possible.
*/
public function validateSort(RepositoryInterface $object, array $options)
{
if (isset($options['sort'])) {
$direction = null;
if (isset($options['direction'])) {
$direction = strtolower($options['direction']);
}
if (!in_array($direction, ['asc', 'desc'])) {
$direction = 'asc';
}
$order = (isset($options['order']) && is_array($options['order'])) ? $options['order'] : [];
if ($order && $options['sort'] && strpos($options['sort'], '.') === false) {
$order = $this->_removeAliases($order, $object->getAlias());
}
$options['order'] = [$options['sort'] => $direction] + $order;
} else {
$options['sort'] = null;
}
unset($options['direction']);
if (empty($options['order'])) {
$options['order'] = [];
}
if (!is_array($options['order'])) {
return $options;
}
$inWhitelist = false;
if (isset($options['sortWhitelist'])) {
$field = key($options['order']);
$inWhitelist = in_array($field, $options['sortWhitelist'], true);
if (!$inWhitelist) {
$options['order'] = [];
$options['sort'] = null;
return $options;
}
}
if ($options['sort'] === null
&& count($options['order']) === 1
&& !is_numeric(key($options['order']))
) {
$options['sort'] = key($options['order']);
}
$options['order'] = $this->_prefix($object, $options['order'], $inWhitelist);
return $options;
}
/**
* Remove alias if needed.
*
* @param array $fields Current fields
* @param string $model Current model alias
* @return array $fields Unaliased fields where applicable
*/
protected function _removeAliases($fields, $model)
{
$result = [];
foreach ($fields as $field => $sort) {
if (strpos($field, '.') === false) {
$result[$field] = $sort;
continue;
}
list ($alias, $currentField) = explode('.', $field);
if ($alias === $model) {
$result[$currentField] = $sort;
continue;
}
$result[$field] = $sort;
}
return $result;
}
/**
* Prefixes the field with the table alias if possible.
*
* @param \Cake\Datasource\RepositoryInterface $object Repository object.
* @param array $order Order array.
* @param bool $whitelisted Whether or not the field was whitelisted.
* @return array Final order array.
*/
protected function _prefix(RepositoryInterface $object, $order, $whitelisted = false)
{
$tableAlias = $object->getAlias();
$tableOrder = [];
foreach ($order as $key => $value) {
if (is_numeric($key)) {
$tableOrder[] = $value;
continue;
}
$field = $key;
$alias = $tableAlias;
if (strpos($key, '.') !== false) {
list($alias, $field) = explode('.', $key);
}
$correctAlias = ($tableAlias === $alias);
if ($correctAlias && $whitelisted) {
// Disambiguate fields in schema. As id is quite common.
if ($object->hasField($field)) {
$field = $alias . '.' . $field;
}
$tableOrder[$field] = $value;
} elseif ($correctAlias && $object->hasField($field)) {
$tableOrder[$tableAlias . '.' . $field] = $value;
} elseif (!$correctAlias && $whitelisted) {
$tableOrder[$alias . '.' . $field] = $value;
}
}
return $tableOrder;
}
/**
* Check the limit parameter and ensure it's within the maxLimit bounds.
*
* @param array $options An array of options with a limit key to be checked.
* @return array An array of options for pagination.
*/
public function checkLimit(array $options)
{
$options['limit'] = (int)$options['limit'];
if (empty($options['limit']) || $options['limit'] < 1) {
$options['limit'] = 1;
}
$options['limit'] = max(min($options['limit'], $options['maxLimit']), 1);
return $options;
}
}