Browse Source

Merge pull request #10718 from cakephp/standalone-paginator

Add standalone Paginator class.
Mark Story 8 years ago
parent
commit
07f3fe47d8

+ 113 - 199
src/Controller/Component/PaginatorComponent.php

@@ -15,10 +15,12 @@
 namespace Cake\Controller\Component;
 
 use Cake\Controller\Component;
+use Cake\Controller\ComponentRegistry;
+use Cake\Datasource\Exception\PageOutOfBoundsException;
+use Cake\Datasource\Paginator;
+use Cake\Datasource\PaginatorInterface;
 use Cake\Datasource\QueryInterface;
-use Cake\Datasource\RepositoryInterface;
 use Cake\Network\Exception\NotFoundException;
-use Cake\Utility\Hash;
 
 /**
  * This component is used to handle automatic model data pagination. The primary way to use this
@@ -56,6 +58,28 @@ class PaginatorComponent extends Component
     ];
 
     /**
+     * Datasource paginator instance.
+     *
+     * @var \Cake\Datasource\PaginatorInterface
+     */
+    protected $_paginator;
+
+    /**
+     * {@inheritDoc}
+     */
+    public function __construct(ComponentRegistry $registry, array $config = [])
+    {
+        if (isset($config['paginator'])) {
+            $this->_paginator = $config['paginator'];
+            unset($config['paginator']);
+        } else {
+            $this->_paginator = new Paginator();
+        }
+
+        parent::__construct($registry, $config);
+    }
+
+    /**
      * Events supported by this component.
      *
      * @return array
@@ -165,73 +189,19 @@ class PaginatorComponent extends Component
      */
     public function paginate($object, array $settings = [])
     {
-        $query = null;
-        if ($object instanceof QueryInterface) {
-            $query = $object;
-            $object = $query->repository();
-        }
-
-        $alias = $object->alias();
-        $options = $this->mergeOptions($alias, $settings);
-        $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);
-
-        /* @var \Cake\Datasource\RepositoryInterface $object */
-        if (empty($query)) {
-            $query = $object->find($finder, $options);
-        } else {
-            $query->applyOptions($options);
-        }
-
-        $cleanQuery = clone $query;
-        $results = $query->all();
-        $numResults = count($results);
-        $count = $numResults ? $cleanQuery->count() : 0;
-
-        $defaults = $this->getDefaults($alias, $settings);
-        unset($defaults[0]);
-
-        $page = $options['page'];
-        $limit = $options['limit'];
-        $pageCount = (int)ceil($count / $limit);
-        $requestedPage = $page;
-        $page = max(min($page, $pageCount), 1);
         $request = $this->_registry->getController()->request;
 
-        $order = (array)$options['order'];
-        $sortDefault = $directionDefault = false;
-        if (!empty($defaults['order']) && count($defaults['order']) == 1) {
-            $sortDefault = key($defaults['order']);
-            $directionDefault = current($defaults['order']);
-        }
+        try {
+            $results = $this->_paginator->paginate(
+                $object,
+                $request->getQueryParams(),
+                $settings
+            );
 
-        $paging = [
-            'finder' => $finder,
-            'page' => $page,
-            'current' => $numResults,
-            'count' => $count,
-            'perPage' => $limit,
-            'prevPage' => $page > 1,
-            'nextPage' => $count > ($page * $limit),
-            'pageCount' => $pageCount,
-            'sort' => key($order),
-            'direction' => current($order),
-            'limit' => $defaults['limit'] != $limit ? $limit : null,
-            'sortDefault' => $sortDefault,
-            'directionDefault' => $directionDefault,
-            'scope' => $options['scope'],
-        ];
+            $this->_setPagingParams();
+        } catch (PageOutOfBoundsException $e) {
+            $this->_setPagingParams();
 
-        if (!$request->getParam('paging')) {
-            $request->params['paging'] = [];
-        }
-        $request->params['paging'] = [$alias => $paging] + (array)$request->getParam('paging');
-
-        if ($requestedPage > $page) {
             throw new NotFoundException();
         }
 
@@ -239,26 +209,6 @@ class PaginatorComponent extends Component
     }
 
     /**
-     * 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];
-    }
-
-    /**
      * Merges the various options that Pagination uses.
      * Pulls settings together from the following places:
      *
@@ -276,155 +226,119 @@ class PaginatorComponent extends Component
      */
     public function mergeOptions($alias, $settings)
     {
-        $defaults = $this->getDefaults($alias, $settings);
         $request = $this->_registry->getController()->request;
-        $scope = Hash::get($settings, 'scope', null);
-        $query = $request->getQueryParams();
-        if ($scope) {
-            $query = Hash::get($request->getQueryParams(), $scope, []);
-        }
-        $request = array_intersect_key($query, array_flip($this->_config['whitelist']));
 
-        return array_merge($defaults, $request);
+        return $this->_paginator->mergeOptions(
+            $request->getQueryParams(),
+            $this->_paginator->getDefaults($alias, $settings)
+        );
     }
 
     /**
-     * Get the settings for a $model. If there are no settings for a specific model, the general settings
-     * will be used.
+     * Set paginator instance.
      *
-     * @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.
+     * @param \Cake\Datasource\PaginatorInterface $paginator Paginator instance.
+     * @return self
      */
-    public function getDefaults($alias, $settings)
+    public function setPaginator(PaginatorInterface $paginator)
     {
-        if (isset($settings[$alias])) {
-            $settings = $settings[$alias];
-        }
+        $this->_paginator = $paginator;
 
-        $defaults = $this->getConfig();
-        $maxLimit = isset($settings['maxLimit']) ? $settings['maxLimit'] : $defaults['maxLimit'];
-        $limit = isset($settings['limit']) ? $settings['limit'] : $defaults['limit'];
+        return $this;
+    }
 
-        if ($limit > $maxLimit) {
-            $limit = $maxLimit;
-        }
+    /**
+     * Get paginator instance.
+     *
+     * @return \Cake\Datasource\PaginatorInterface
+     */
+    public function getPaginator()
+    {
+        return $this->_paginator;
+    }
 
-        $settings['maxLimit'] = $maxLimit;
-        $settings['limit'] = $limit;
+    /**
+     * Set paging params to request instance.
+     *
+     * @return void
+     */
+    protected function _setPagingParams()
+    {
+        $request = $this->_registry->getController()->request;
 
-        return $settings + $defaults;
+        $request->addParams([
+            'paging' => $this->_paginator->getPagingParams()
+                + (array)$request->getParam('paging')
+        ]);
     }
 
     /**
-     * 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.
-     * This helps prevent users from ordering large result sets on un-indexed values.
+     * Proxy getting/setting config options to Paginator.
      *
-     * 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.
-     *
-     * @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.
+     * @param string|array|null $key The key to get/set, or a complete array of configs.
+     * @param mixed|null $value The value to set.
+     * @param bool $merge Whether to recursively merge or overwrite existing config, defaults to true.
+     * @return mixed Config value being read, or the object itself on write operations.
      */
-    public function validateSort(RepositoryInterface $object, array $options)
+    public function config($key = null, $value = null, $merge = true)
     {
-        if (isset($options['sort'])) {
-            $direction = null;
-            if (isset($options['direction'])) {
-                $direction = strtolower($options['direction']);
-            }
-            if (!in_array($direction, ['asc', 'desc'])) {
-                $direction = 'asc';
-            }
-            $options['order'] = [$options['sort'] => $direction];
-        }
-        unset($options['sort'], $options['direction']);
-
-        if (empty($options['order'])) {
-            $options['order'] = [];
+        $return = $this->_paginator->config($key, $value, $merge);
+        if ($return instanceof PaginatorInterface) {
+            $return = $this;
         }
-        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'] = [];
 
-                return $options;
-            }
-        }
+        return $return;
+    }
 
-        $options['order'] = $this->_prefix($object, $options['order'], $inWhitelist);
+    /**
+     * Proxy setting config options to Paginator.
+     *
+     * @param string|array $key The key to set, or a complete array of configs.
+     * @param mixed|null $value The value to set.
+     * @param bool $merge Whether to recursively merge or overwrite existing config, defaults to true.
+     * @return $this
+     */
+    public function setConfig($key, $value = null, $merge = true)
+    {
+        $this->_paginator->setConfig($key, $value, $merge);
 
-        return $options;
+        return $this;
     }
 
     /**
-     * Prefixes the field with the table alias if possible.
+     * Proxy getting config options to Paginator.
      *
-     * @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.
+     * @param string|null $key The key to get or null for the whole config.
+     * @return mixed Config value being read.
      */
-    protected function _prefix(RepositoryInterface $object, $order, $whitelisted = false)
+    public function getConfig($key = null)
     {
-        $tableAlias = $object->alias();
-        $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);
+        return $this->_paginator->getConfig($key);
+    }
 
-            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;
-            }
-        }
+    /**
+     * Proxy setting config options to Paginator.
+     *
+     * @param string|array $key The key to set, or a complete array of configs.
+     * @param mixed|null $value The value to set.
+     * @return $this
+     */
+    public function configShallow($key, $value = null)
+    {
+        $this->_paginator->configShallow($key, $value = null);
 
-        return $tableOrder;
+        return $this;
     }
 
     /**
-     * Check the limit parameter and ensure it's within the maxLimit bounds.
+     * Proxy method calls to Paginator.
      *
-     * @param array $options An array of options with a limit key to be checked.
-     * @return array An array of options for pagination
+     * @param string $method Method name.
+     * @param array $args Method arguments.
+     * @return mixed
      */
-    public function checkLimit(array $options)
+    public function __call($method, $args)
     {
-        $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;
+        return call_user_func_array([$this->_paginator, $method], $args);
     }
 }

+ 38 - 0
src/Datasource/Exception/PageOutOfBoundsException.php

@@ -0,0 +1,38 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @since         3.5.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Datasource\Exception;
+
+use Cake\Core\Exception\Exception;
+
+/**
+ * Exception raised when requested page number does not exist.
+ */
+class PageOutOfBoundsException extends Exception
+{
+    /**
+     * {@inheritDoc}
+     */
+    protected $_messageTemplate = 'Page number "%s" could not be found.';
+
+    /**
+     * Constructor
+     *
+     * @param string|null $message The error message.
+     * @param int $code The code of the error, is also the HTTP status code for the error.
+     * @param \Exception|null $previous The previous exception.
+     */
+    public function __construct($message = null, $code = 404, $previous = null)
+    {
+        parent::__construct($message, $code, $previous = null);
+    }
+}

+ 432 - 0
src/Datasource/Paginator.php

@@ -0,0 +1,432 @@
+<?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\ORM\Exception\PageOutOfBoundsException
+     */
+    public function paginate($object, array $params = [], array $settings = [])
+    {
+        $query = null;
+        if ($object instanceof QueryInterface) {
+            $query = $object;
+            $object = $query->repository();
+        }
+
+        $alias = $object->alias();
+        $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 = $numResults ? $cleanQuery->count() : 0;
+
+        $page = $options['page'];
+        $limit = $options['limit'];
+        $pageCount = (int)ceil($count / $limit);
+        $requestedPage = $page;
+        $page = max(min($page, $pageCount), 1);
+
+        $order = (array)$options['order'];
+        $sortDefault = $directionDefault = false;
+        if (!empty($defaults['order']) && count($defaults['order']) == 1) {
+            $sortDefault = key($defaults['order']);
+            $directionDefault = current($defaults['order']);
+        }
+
+        $paging = [
+            'finder' => $finder,
+            'page' => $page,
+            'current' => $numResults,
+            'count' => $count,
+            'perPage' => $limit,
+            'prevPage' => $page > 1,
+            'nextPage' => $count > ($page * $limit),
+            'pageCount' => $pageCount,
+            'sort' => key($order),
+            'direction' => current($order),
+            'limit' => $defaults['limit'] != $limit ? $limit : null,
+            'sortDefault' => $sortDefault,
+            'directionDefault' => $directionDefault,
+            'scope' => $options['scope'],
+        ];
+
+        $this->_pagingParams = [$alias => $paging];
+
+        if ($requestedPage > $page) {
+            throw new PageOutOfBoundsException([$requestedPage]);
+        }
+
+        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->config('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. 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.
+     *
+     * @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';
+            }
+            $options['order'] = [$options['sort'] => $direction];
+        }
+        unset($options['sort'], $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'] = [];
+
+                return $options;
+            }
+        }
+
+        $options['order'] = $this->_prefix($object, $options['order'], $inWhitelist);
+
+        return $options;
+    }
+
+    /**
+     * 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->alias();
+        $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;
+    }
+}

+ 38 - 0
src/Datasource/PaginatorInterface.php

@@ -0,0 +1,38 @@
+<?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;
+
+/**
+ * This interface describes the methods for paginator instance.
+ */
+interface PaginatorInterface
+{
+    /**
+     * Handles pagination of datasource records.
+     *
+     * @param mixed $object The repository or query to paginate.
+     * @param array $params Request params
+     * @param array $settings The settings/configuration used for pagination.
+     * @return mixed Query results
+     */
+    public function paginate($object, array $params = [], array $settings = []);
+
+    /**
+     * Get paging params after pagination operation.
+     *
+     * @return array
+     */
+    public function getPagingParams();
+}

+ 31 - 2
tests/TestCase/Controller/Component/PaginatorComponentTest.php

@@ -20,6 +20,7 @@ use Cake\Controller\Controller;
 use Cake\Core\Configure;
 use Cake\Datasource\ConnectionManager;
 use Cake\Datasource\EntityInterface;
+use Cake\Datasource\Paginator;
 use Cake\Http\ServerRequest;
 use Cake\Network\Exception\NotFoundException;
 use Cake\ORM\Entity;
@@ -40,6 +41,13 @@ class PaginatorTestController extends Controller
     public $components = ['Paginator'];
 }
 
+/**
+ * Custom paginator
+ */
+class CustomPaginator extends Paginator
+{
+}
+
 class PaginatorComponentTest extends TestCase
 {
 
@@ -74,8 +82,8 @@ class PaginatorComponentTest extends TestCase
         $this->request = new ServerRequest('controller_posts/index');
         $this->request->params['pass'] = [];
         $controller = new Controller($this->request);
-        $registry = new ComponentRegistry($controller);
-        $this->Paginator = new PaginatorComponent($registry, []);
+        $this->registry = new ComponentRegistry($controller);
+        $this->Paginator = new PaginatorComponent($this->registry, []);
 
         $this->Post = $this->getMockBuilder('Cake\Datasource\RepositoryInterface')
             ->disableOriginalConstructor()
@@ -94,6 +102,27 @@ class PaginatorComponentTest extends TestCase
     }
 
     /**
+     * testPaginatorSetting
+     *
+     * @return void
+     */
+    public function testPaginatorSetting()
+    {
+        $paginator = new CustomPaginator();
+        $component = new PaginatorComponent($this->registry, [
+            'paginator' => $paginator
+        ]);
+
+        $this->assertSame($paginator, $component->getPaginator());
+
+        $component = new PaginatorComponent($this->registry, []);
+        $this->assertNotSame($paginator, $component->getPaginator());
+
+        $component->setPaginator($paginator);
+        $this->assertSame($paginator, $component->getPaginator());
+    }
+
+    /**
      * Test that non-numeric values are rejected for page, and limit
      *
      * @return void

File diff suppressed because it is too large
+ 1269 - 0
tests/TestCase/Datasource/PaginatorTest.php