init
This commit is contained in:
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
|
||||
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
|
||||
* @since 3.5.0
|
||||
* @license https://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
namespace Cake\Datasource\Paging\Exception;
|
||||
|
||||
use Cake\Core\Exception\CakeException;
|
||||
use Cake\Core\Exception\HttpErrorCodeInterface;
|
||||
|
||||
/**
|
||||
* Exception raised when requested page number does not exist.
|
||||
*/
|
||||
class PageOutOfBoundsException extends CakeException implements HttpErrorCodeInterface
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected int $_defaultCode = 404;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected string $_messageTemplate = 'Page number `%s` could not be found.';
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
|
||||
* Copyright (c) Cake Software Foundation, Inc. (https://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. (https://cakefoundation.org)
|
||||
* @link https://cakephp.org CakePHP(tm) Project
|
||||
* @since 3.5.0
|
||||
* @license https://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
namespace Cake\Datasource\Paging;
|
||||
|
||||
use Cake\Core\Exception\CakeException;
|
||||
use Cake\Core\InstanceConfigTrait;
|
||||
use Cake\Datasource\Paging\Exception\PageOutOfBoundsException;
|
||||
use Cake\Datasource\QueryInterface;
|
||||
use Cake\Datasource\RepositoryInterface;
|
||||
use Cake\Datasource\ResultSetInterface;
|
||||
use function Cake\Core\triggerWarning;
|
||||
|
||||
/**
|
||||
* This class is used to handle automatic model data pagination.
|
||||
*/
|
||||
class NumericPaginator 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.
|
||||
* - `allowedParameters` - 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.
|
||||
* - `sortableFields` - Controls which fields can be used for sorting. Accepts multiple formats:
|
||||
* - Simple array: A list of field names that can be sorted. By default all table
|
||||
* columns can be used. Use this to restrict sorting to specific fields. An empty
|
||||
* array will disable sorting altogether.
|
||||
* - Map with SortField objects: A map of sort keys to their corresponding database fields.
|
||||
* Allows creating friendly sort keys that map to one or more actual fields. Supports
|
||||
* simple mapping, multi-column sorting, locked directions, and default directions.
|
||||
* Can accept a callable that receives a SortableFieldsBuilder instance.
|
||||
*
|
||||
* Examples:
|
||||
* ```
|
||||
* // Simple array (traditional)
|
||||
* 'sortableFields' => ['title', 'created', 'author_id']
|
||||
*
|
||||
* // Map with SortField objects
|
||||
* 'sortableFields' => [
|
||||
* 'name' => 'Users.name',
|
||||
* 'newest' => [
|
||||
* SortField::desc('created'),
|
||||
* SortField::asc('title'),
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* // Callable with builder
|
||||
* 'sortableFields' => function(SortableFieldsBuilder $builder) {
|
||||
* return $builder
|
||||
* ->add('name', SortField::asc('Users.name'))
|
||||
* ->add('popularity', SortField::desc('score', locked: true), 'created');
|
||||
* }
|
||||
* ```
|
||||
* - `finder` - The table finder to use. Defaults to `all`.
|
||||
* - `scope` - If specified this scope will be used to get the paging options
|
||||
* from the query params passed to paginate(). Scopes allow namespacing the
|
||||
* paging options and allows paginating multiple models in the same action.
|
||||
* Default `null`.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $_defaultConfig = [
|
||||
'page' => 1,
|
||||
'limit' => 20,
|
||||
'maxLimit' => 100,
|
||||
'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
|
||||
'sortableFields' => null,
|
||||
'finder' => 'all',
|
||||
'scope' => null,
|
||||
];
|
||||
|
||||
/**
|
||||
* Calculated paging params.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pagingParams = [
|
||||
'limit' => null,
|
||||
'maxLimit' => null,
|
||||
'count' => null,
|
||||
'totalCount' => null,
|
||||
'perPage' => null,
|
||||
'pageCount' => null,
|
||||
'currentPage' => null,
|
||||
'requestedPage' => null,
|
||||
'start' => null,
|
||||
'end' => null,
|
||||
'hasPrevPage' => null,
|
||||
'hasNextPage' => null,
|
||||
'sort' => null,
|
||||
'sortDefault' => null,
|
||||
'direction' => null,
|
||||
'directionDefault' => null,
|
||||
'completeSort' => null,
|
||||
'alias' => null,
|
||||
'scope' => null,
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 an allowed list of all the columns you wish to allow
|
||||
* sorting on. You can define the allowed sort fields in the `$settings` parameter:
|
||||
*
|
||||
* ```
|
||||
* $settings = [
|
||||
* 'Articles' => [
|
||||
* 'finder' => 'custom',
|
||||
* 'sortableFields' => ['title', 'author_id', 'comment_count'],
|
||||
* ]
|
||||
* ];
|
||||
* ```
|
||||
*
|
||||
* Passing an empty array as sortableFields 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 mixed $target The repository or query
|
||||
* to paginate.
|
||||
* @param array $params Request params
|
||||
* @param array $settings The settings/configuration used for pagination.
|
||||
* @return \Cake\Datasource\Paging\PaginatedInterface
|
||||
* @throws \Cake\Datasource\Paging\Exception\PageOutOfBoundsException
|
||||
*/
|
||||
public function paginate(
|
||||
mixed $target,
|
||||
array $params = [],
|
||||
array $settings = [],
|
||||
): PaginatedInterface {
|
||||
$query = null;
|
||||
if ($target instanceof QueryInterface) {
|
||||
$query = $target;
|
||||
$target = $query->getRepository();
|
||||
if ($target === null) {
|
||||
throw new CakeException('No repository set for query.');
|
||||
}
|
||||
}
|
||||
|
||||
assert(
|
||||
$target instanceof RepositoryInterface,
|
||||
'Pagination target must be an instance of `' . QueryInterface::class
|
||||
. '` or `' . RepositoryInterface::class . '`.',
|
||||
);
|
||||
|
||||
$data = $this->extractData($target, $params, $settings);
|
||||
$query = $this->getQuery($target, $query, $data);
|
||||
|
||||
$countQuery = clone $query;
|
||||
$items = $this->getItems($query, $data);
|
||||
$this->pagingParams['count'] = count($items);
|
||||
$this->pagingParams['totalCount'] = $this->getCount($countQuery, $data);
|
||||
|
||||
$pagingParams = $this->buildParams($data);
|
||||
if ($pagingParams['requestedPage'] > $pagingParams['currentPage']) {
|
||||
throw new PageOutOfBoundsException([
|
||||
'requestedPage' => $pagingParams['requestedPage'],
|
||||
'pagingParams' => $pagingParams,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->buildPaginated($items, $pagingParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build paginated result set.
|
||||
*
|
||||
* @param \Cake\Datasource\ResultSetInterface $items
|
||||
* @param array $pagingParams
|
||||
* @return \Cake\Datasource\Paging\PaginatedInterface
|
||||
*/
|
||||
protected function buildPaginated(ResultSetInterface $items, array $pagingParams): PaginatedInterface
|
||||
{
|
||||
return new PaginatedResultSet($items, $pagingParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get query for fetching paginated results.
|
||||
*
|
||||
* @param \Cake\Datasource\RepositoryInterface $object Repository instance.
|
||||
* @param \Cake\Datasource\QueryInterface|null $query Query Instance.
|
||||
* @param array<string, mixed> $data Pagination data.
|
||||
* @return \Cake\Datasource\QueryInterface
|
||||
*/
|
||||
protected function getQuery(RepositoryInterface $object, ?QueryInterface $query, array $data): QueryInterface
|
||||
{
|
||||
$options = $data['options'];
|
||||
$queryOptions = array_intersect_key(
|
||||
$options,
|
||||
['order' => null, 'page' => null, 'limit' => null],
|
||||
);
|
||||
|
||||
$args = [];
|
||||
$type = $options['finder'] ?? null;
|
||||
if (is_array($type)) {
|
||||
$args = (array)current($type);
|
||||
$type = key($type);
|
||||
}
|
||||
|
||||
if ($query === null) {
|
||||
$query = $object->find($type ?? 'all', ...$args);
|
||||
} elseif ($type !== null) {
|
||||
$query->find($type, ...$args);
|
||||
}
|
||||
|
||||
$query->applyOptions($queryOptions);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated items.
|
||||
*
|
||||
* @param \Cake\Datasource\QueryInterface $query Query to fetch items.
|
||||
* @param array $data Paging data.
|
||||
* @return \Cake\Datasource\ResultSetInterface
|
||||
*/
|
||||
protected function getItems(QueryInterface $query, array $data): ResultSetInterface
|
||||
{
|
||||
return $query->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of records.
|
||||
*
|
||||
* @param \Cake\Datasource\QueryInterface $query Query instance.
|
||||
* @param array $data Pagination data.
|
||||
* @return int|null
|
||||
*/
|
||||
protected function getCount(QueryInterface $query, array $data): ?int
|
||||
{
|
||||
return $query->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract pagination data needed
|
||||
*
|
||||
* @param \Cake\Datasource\RepositoryInterface $object The repository object.
|
||||
* @param array<string, mixed> $params Request params
|
||||
* @param array<string, mixed> $settings The settings/configuration used for pagination.
|
||||
* @return array
|
||||
*/
|
||||
protected function extractData(RepositoryInterface $object, array $params, array $settings): array
|
||||
{
|
||||
$alias = $object->getAlias();
|
||||
$defaults = $this->getDefaults($alias, $settings);
|
||||
|
||||
$validSettings = array_keys($this->_defaultConfig);
|
||||
$validSettings[] = 'order';
|
||||
$extraSettings = array_diff_key($defaults, array_flip($validSettings));
|
||||
if ($extraSettings) {
|
||||
triggerWarning(
|
||||
'Passing query options as paginator settings is no longer supported.'
|
||||
. ' Use a custom finder through the `finder` config or pass a SelectQuery instance to paginate().'
|
||||
. ' Extra keys found are: `' . implode('`, `', array_keys($extraSettings)) . '`.',
|
||||
);
|
||||
}
|
||||
|
||||
$options = $this->mergeOptions($params, $defaults);
|
||||
$options = $this->validateSort($object, $options);
|
||||
$options = $this->checkLimit($options);
|
||||
|
||||
$options['page'] = max((int)$options['page'], 1);
|
||||
|
||||
return compact('defaults', 'options', 'alias');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build pagination params.
|
||||
*
|
||||
* @param array<string, mixed> $data Paginator data containing keys 'options',
|
||||
* 'defaults', 'alias'.
|
||||
* @return array<string, mixed> Paging params.
|
||||
*/
|
||||
protected function buildParams(array $data): array
|
||||
{
|
||||
$this->pagingParams = [
|
||||
'perPage' => $data['options']['limit'],
|
||||
'requestedPage' => $data['options']['page'],
|
||||
'alias' => $data['alias'],
|
||||
'scope' => $data['options']['scope'],
|
||||
'maxLimit' => $data['options']['maxLimit'],
|
||||
] + $this->pagingParams;
|
||||
|
||||
$this->addPageCountParams($data);
|
||||
$this->addStartEndParams($data);
|
||||
$this->addPrevNextParams($data);
|
||||
$this->addSortingParams($data);
|
||||
|
||||
$this->pagingParams['limit'] = $data['defaults']['limit'] != $data['options']['limit']
|
||||
? $data['options']['limit']
|
||||
: null;
|
||||
|
||||
// Add sortableFields configuration for view helpers
|
||||
if (isset($data['options']['sortableFields'])) {
|
||||
$sortableFields = $data['options']['sortableFields'];
|
||||
if ($sortableFields instanceof SortableFieldsBuilder) {
|
||||
$this->pagingParams['sortableFields'] = $sortableFields->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->pagingParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add "currentPage" and "pageCount" params.
|
||||
*
|
||||
* @param array $data Paginator data.
|
||||
* @return void
|
||||
*/
|
||||
protected function addPageCountParams(array $data): void
|
||||
{
|
||||
$page = $data['options']['page'];
|
||||
$pageCount = null;
|
||||
|
||||
if ($this->pagingParams['totalCount'] !== null) {
|
||||
$pageCount = max((int)ceil($this->pagingParams['totalCount'] / $this->pagingParams['perPage']), 1);
|
||||
$page = min($page, $pageCount);
|
||||
} elseif ($this->pagingParams['count'] === 0 && $this->pagingParams['requestedPage'] > 1) {
|
||||
$page = 1;
|
||||
}
|
||||
|
||||
$this->pagingParams['currentPage'] = $page;
|
||||
$this->pagingParams['pageCount'] = $pageCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add "start" and "end" params.
|
||||
*
|
||||
* @param array $data Paginator data.
|
||||
* @return void
|
||||
*/
|
||||
protected function addStartEndParams(array $data): void
|
||||
{
|
||||
$start = 0;
|
||||
$end = 0;
|
||||
if ($this->pagingParams['count'] > 0) {
|
||||
$start = (($this->pagingParams['currentPage'] - 1) * $this->pagingParams['perPage']) + 1;
|
||||
$end = $start + $this->pagingParams['count'] - 1;
|
||||
}
|
||||
|
||||
$this->pagingParams['start'] = $start;
|
||||
$this->pagingParams['end'] = $end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add "prevPage" and "nextPage" params.
|
||||
*
|
||||
* @param array $data Paging data.
|
||||
* @return void
|
||||
*/
|
||||
protected function addPrevNextParams(array $data): void
|
||||
{
|
||||
$this->pagingParams['hasPrevPage'] = $this->pagingParams['currentPage'] > 1;
|
||||
if ($this->pagingParams['totalCount'] === null) {
|
||||
$this->pagingParams['hasNextPage'] = true;
|
||||
} else {
|
||||
$this->pagingParams['hasNextPage'] = $this->pagingParams['totalCount']
|
||||
> $this->pagingParams['currentPage'] * $this->pagingParams['perPage'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add sorting / ordering params.
|
||||
*
|
||||
* @param array $data Paging data.
|
||||
* @return void
|
||||
*/
|
||||
protected function addSortingParams(array $data): void
|
||||
{
|
||||
$defaults = $data['defaults'];
|
||||
$order = (array)$data['options']['order'];
|
||||
$sortDefault = false;
|
||||
$directionDefault = false;
|
||||
|
||||
if (!empty($defaults['order']) && count($defaults['order']) >= 1) {
|
||||
$sortDefault = key($defaults['order']);
|
||||
$directionDefault = current($defaults['order']);
|
||||
}
|
||||
if (isset($data['options']['sortDirection'])) {
|
||||
$direction = $data['options']['sortDirection'];
|
||||
} else {
|
||||
$direction = isset($data['options']['sort']) && count($order) ? current($order) : null;
|
||||
}
|
||||
|
||||
$this->pagingParams = [
|
||||
'sort' => $data['options']['sort'],
|
||||
'direction' => $direction,
|
||||
'sortDefault' => $sortDefault,
|
||||
'directionDefault' => $directionDefault,
|
||||
'completeSort' => $order,
|
||||
] + $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 `allowedParameters` to modify
|
||||
* which options/values can be set using request parameters.
|
||||
*
|
||||
* @param array<string, mixed> $params Request params.
|
||||
* @param array $settings The settings to merge with the request data.
|
||||
* @return array<string, mixed> Array of merged options.
|
||||
*/
|
||||
protected function mergeOptions(array $params, array $settings): array
|
||||
{
|
||||
if (!empty($settings['scope'])) {
|
||||
$scope = $settings['scope'];
|
||||
$params = !empty($params[$scope]) ? (array)$params[$scope] : [];
|
||||
}
|
||||
$params = array_intersect_key($params, array_flip($this->getConfig('allowedParameters')));
|
||||
|
||||
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<string, mixed> $settings The settings which is used for combining.
|
||||
* @return array<string, mixed> An array of pagination settings for a model,
|
||||
* or the general settings.
|
||||
*/
|
||||
protected function getDefaults(string $alias, array $settings): array
|
||||
{
|
||||
if (isset($settings[$alias])) {
|
||||
$settings = $settings[$alias];
|
||||
}
|
||||
|
||||
$defaults = $this->getConfig();
|
||||
|
||||
$maxLimit = $settings['maxLimit'] ?? $defaults['maxLimit'];
|
||||
$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 allowedParameters option 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 the `sortableFields` option.
|
||||
*
|
||||
* Any columns listed in the allowed sort fields 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<string, mixed> $options The pagination options being used for this request.
|
||||
* @return array<string, mixed> An array of options with sort + direction removed and
|
||||
* replaced with order if possible.
|
||||
*/
|
||||
protected function validateSort(RepositoryInterface $object, array $options): array
|
||||
{
|
||||
// Check if we have sortableFields configured
|
||||
$sortableFields = $options['sortableFields'] ?? null;
|
||||
$builder = $sortableFields instanceof SortableFieldsBuilder
|
||||
? $sortableFields
|
||||
: SortableFieldsBuilder::create($sortableFields);
|
||||
|
||||
// Store the converted builder for later use in paging params
|
||||
if ($builder !== null) {
|
||||
$options['sortableFields'] = $builder;
|
||||
}
|
||||
|
||||
$sortAllowed = $builder !== null;
|
||||
|
||||
if (isset($options['sort'])) {
|
||||
// Parse sort and direction parameters
|
||||
$sortParams = $this->parseSortParams($options);
|
||||
|
||||
// Update options with parsed sort key (handles combined format)
|
||||
$options['sort'] = $sortParams['sortKey'];
|
||||
|
||||
if ($builder !== null) {
|
||||
// Use builder to resolve sort key
|
||||
$order = $builder->resolve(
|
||||
$sortParams['sortKey'],
|
||||
$sortParams['direction'],
|
||||
$sortParams['directionSpecified'],
|
||||
);
|
||||
|
||||
if ($order === null) {
|
||||
// Invalid sort key, clear sort
|
||||
$options['order'] = [];
|
||||
$options['sort'] = null;
|
||||
unset($options['direction']);
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
// Merge with existing order - existing order comes AFTER our resolved order
|
||||
$existingOrder = isset($options['order']) && is_array($options['order']) ? $options['order'] : [];
|
||||
// Only keep fields from existing order that aren't already in our resolved order
|
||||
foreach ($existingOrder as $field => $dir) {
|
||||
if (!isset($order[$field])) {
|
||||
$order[$field] = $dir;
|
||||
}
|
||||
}
|
||||
$options['order'] = $order;
|
||||
$options['sortDirection'] = $sortParams['direction'];
|
||||
} else {
|
||||
// No sortableFields configured - allow any field (default behavior)
|
||||
$order = isset($options['order']) && is_array($options['order']) ? $options['order'] : [];
|
||||
if ($order && $sortParams['sortKey'] && !str_contains($sortParams['sortKey'], '.')) {
|
||||
$order = $this->_removeAliases($order, $object->getAlias());
|
||||
}
|
||||
|
||||
$options['order'] = [$sortParams['sortKey'] => $sortParams['direction']] + $order;
|
||||
}
|
||||
} else {
|
||||
$options['sort'] = null;
|
||||
}
|
||||
|
||||
unset($options['direction']);
|
||||
|
||||
if (empty($options['order'])) {
|
||||
$options['order'] = [];
|
||||
}
|
||||
if (!is_array($options['order'])) {
|
||||
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'], $sortAllowed);
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove alias if needed.
|
||||
*
|
||||
* @param array<string, mixed> $fields Current fields
|
||||
* @param string $model Current model alias
|
||||
* @return array<string, mixed> $fields Unaliased fields where applicable
|
||||
*/
|
||||
protected function _removeAliases(array $fields, string $model): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($fields as $field => $sort) {
|
||||
if (is_int($field)) {
|
||||
throw new CakeException(sprintf(
|
||||
'The `order` config must be an associative array. Found invalid value with numeric key: `%s`',
|
||||
$sort,
|
||||
));
|
||||
}
|
||||
|
||||
if (!str_contains($field, '.')) {
|
||||
$result[$field] = $sort;
|
||||
continue;
|
||||
}
|
||||
|
||||
[$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 $allowed Whether the field was allowed.
|
||||
* @return array Final order array.
|
||||
*/
|
||||
protected function _prefix(RepositoryInterface $object, array $order, bool $allowed = false): array
|
||||
{
|
||||
$tableAlias = $object->getAlias();
|
||||
$tableOrder = [];
|
||||
foreach ($order as $key => $value) {
|
||||
if (is_numeric($key)) {
|
||||
$tableOrder[] = $value;
|
||||
continue;
|
||||
}
|
||||
$field = $key;
|
||||
$alias = $tableAlias;
|
||||
|
||||
if (str_contains($key, '.')) {
|
||||
[$alias, $field] = explode('.', $key);
|
||||
}
|
||||
$correctAlias = ($tableAlias === $alias);
|
||||
|
||||
if ($correctAlias && $allowed) {
|
||||
// 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 && $allowed) {
|
||||
$tableOrder[$alias . '.' . $field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $tableOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse sort parameters from options.
|
||||
*
|
||||
* Extracts and normalizes sort key and direction from pagination options.
|
||||
* Supports both traditional format (?sort=field&direction=asc) and
|
||||
* combined format (?sort=field-asc).
|
||||
*
|
||||
* @param array<string, mixed> $options The options array
|
||||
* @return array{sortKey: string, direction: string, directionSpecified: bool}
|
||||
*/
|
||||
protected function parseSortParams(array $options): array
|
||||
{
|
||||
$sortKey = $options['sort'];
|
||||
$direction = isset($options['direction']) ? strtolower($options['direction']) : SortField::ASC;
|
||||
$directionSpecified = isset($options['direction']);
|
||||
|
||||
// Check for combined sort-direction format (e.g., 'title-asc' or 'title-desc')
|
||||
if (preg_match('/^(.+)-(asc|desc)$/i', $sortKey, $matches)) {
|
||||
$sortKey = $matches[1];
|
||||
$direction = strtolower($matches[2]);
|
||||
$directionSpecified = true;
|
||||
}
|
||||
|
||||
// Validate direction
|
||||
if (!in_array($direction, [SortField::ASC, SortField::DESC], true)) {
|
||||
$direction = SortField::ASC;
|
||||
}
|
||||
|
||||
return [
|
||||
'sortKey' => $sortKey,
|
||||
'direction' => $direction,
|
||||
'directionSpecified' => $directionSpecified,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the limit parameter and ensure it's within the maxLimit bounds.
|
||||
*
|
||||
* @param array<string, mixed> $options An array of options with a limit key to be checked.
|
||||
* @return array<string, mixed> An array of options for pagination.
|
||||
*/
|
||||
protected function checkLimit(array $options): array
|
||||
{
|
||||
$options['limit'] = (int)$options['limit'];
|
||||
if ($options['limit'] < 1) {
|
||||
$options['limit'] = 1;
|
||||
}
|
||||
$options['limit'] = max(min($options['limit'], $options['maxLimit']), 1);
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 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 5.0.0
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
namespace Cake\Datasource\Paging;
|
||||
|
||||
use Countable;
|
||||
use Traversable;
|
||||
|
||||
/**
|
||||
* This interface describes the methods for pagination instance.
|
||||
*
|
||||
* @template TKey
|
||||
* @template-covariant TValue
|
||||
* @template-extends \Traversable<TKey, TValue>
|
||||
* @method array<mixed> toArray() Get the paginated items as an array
|
||||
*/
|
||||
interface PaginatedInterface extends Countable, Traversable
|
||||
{
|
||||
/**
|
||||
* Get current page number.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function currentPage(): int;
|
||||
|
||||
/**
|
||||
* Get items per page.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function perPage(): int;
|
||||
|
||||
/**
|
||||
* Get Total items counts.
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function totalCount(): ?int;
|
||||
|
||||
/**
|
||||
* Get total page count.
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function pageCount(): ?int;
|
||||
|
||||
/**
|
||||
* Get whether there's a previous page.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasPrevPage(): bool;
|
||||
|
||||
/**
|
||||
* Get whether there's a next page.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasNextPage(): bool;
|
||||
|
||||
/**
|
||||
* Get paginated items.
|
||||
*
|
||||
* @return iterable<TKey, TValue>
|
||||
*/
|
||||
public function items(): iterable;
|
||||
|
||||
/**
|
||||
* Get paging param.
|
||||
*
|
||||
* @param string $name
|
||||
* @return mixed
|
||||
*/
|
||||
public function pagingParam(string $name): mixed;
|
||||
|
||||
/**
|
||||
* Get all paging params.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function pagingParams(): array;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
|
||||
* Copyright (c) Cake Software Foundation, Inc. (https://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. (https://cakefoundation.org)
|
||||
* @link https://cakephp.org CakePHP(tm) Project
|
||||
* @since 5.0.0
|
||||
* @license https://opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
namespace Cake\Datasource\Paging;
|
||||
|
||||
use IteratorAggregate;
|
||||
use JsonSerializable;
|
||||
use Traversable;
|
||||
use function Cake\Core\deprecationWarning;
|
||||
|
||||
/**
|
||||
* Paginated result set.
|
||||
*
|
||||
* @template TKey
|
||||
* @template TValue
|
||||
* @implements \IteratorAggregate<TKey, TValue>
|
||||
* @implements \Cake\Datasource\Paging\PaginatedInterface<TKey, TValue>
|
||||
*/
|
||||
class PaginatedResultSet implements IteratorAggregate, JsonSerializable, PaginatedInterface
|
||||
{
|
||||
/**
|
||||
* Resultset instance.
|
||||
*
|
||||
* @var \Traversable<TKey, TValue>
|
||||
*/
|
||||
protected Traversable $results;
|
||||
|
||||
/**
|
||||
* Paging params.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $params = [];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param \Traversable<TKey, TValue> $results Resultset instance.
|
||||
* @param array $params Paging params.
|
||||
*/
|
||||
public function __construct(Traversable $results, array $params)
|
||||
{
|
||||
$this->results = $results;
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return $this->params['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the paginated items as an array.
|
||||
*
|
||||
* This will exhaust the iterator `items`.
|
||||
*
|
||||
* @return array<array-key, TValue>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->jsonSerialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated items.
|
||||
*
|
||||
* @return \Traversable<TKey, TValue> The paginated items result set.
|
||||
*/
|
||||
public function items(): Traversable
|
||||
{
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide data which should be serialized to JSON.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return iterator_to_array($this->items());
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function totalCount(): ?int
|
||||
{
|
||||
return $this->params['totalCount'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function perPage(): int
|
||||
{
|
||||
return $this->params['perPage'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function pageCount(): ?int
|
||||
{
|
||||
return $this->params['pageCount'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function currentPage(): int
|
||||
{
|
||||
return $this->params['currentPage'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function hasPrevPage(): bool
|
||||
{
|
||||
return $this->params['hasPrevPage'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function hasNextPage(): bool
|
||||
{
|
||||
return $this->params['hasNextPage'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function pagingParam(string $name): mixed
|
||||
{
|
||||
return $this->params[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function pagingParams(): array
|
||||
{
|
||||
return $this->params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies method calls to internal result set instance.
|
||||
*
|
||||
* @param string $name Method name
|
||||
* @param array $arguments Arguments
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
{
|
||||
deprecationWarning(
|
||||
'5.1.0',
|
||||
sprintf(
|
||||
'Calling `%s` methods, such as `%s()`, on PaginatedResultSet is deprecated. ' .
|
||||
'You must call `items()` first (for example, `items()->%s()`).',
|
||||
$this->results::class,
|
||||
$name,
|
||||
$name,
|
||||
),
|
||||
);
|
||||
|
||||
return $this->results->$name(...$arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
|
||||
* Copyright (c) Cake Software Foundation, Inc. (https://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 5.0.0
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
namespace Cake\Datasource\Paging;
|
||||
|
||||
/**
|
||||
* This interface describes the methods for paginator instance.
|
||||
*/
|
||||
interface PaginatorInterface
|
||||
{
|
||||
/**
|
||||
* Handles pagination of data.
|
||||
*
|
||||
* @param mixed $target Anything that needs to be paginated.
|
||||
* @param array $params Request params.
|
||||
* @param array $settings The settings/configuration used for pagination.
|
||||
* @return \Cake\Datasource\Paging\PaginatedInterface
|
||||
*/
|
||||
public function paginate(
|
||||
mixed $target,
|
||||
array $params = [],
|
||||
array $settings = [],
|
||||
): PaginatedInterface;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
|
||||
* Copyright (c) Cake Software Foundation, Inc. (https://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. (https://cakefoundation.org)
|
||||
* @link https://cakephp.org CakePHP(tm) Project
|
||||
* @since 3.9.0
|
||||
* @license https://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
namespace Cake\Datasource\Paging;
|
||||
|
||||
use Cake\Datasource\QueryInterface;
|
||||
use Cake\Datasource\ResultSetInterface;
|
||||
|
||||
/**
|
||||
* Simplified paginator which avoids potentially expensive queries
|
||||
* to get the total count of records.
|
||||
*
|
||||
* When using a simple paginator you will not be able to generate page numbers.
|
||||
* Instead use only the prev/next pagination controls.
|
||||
*/
|
||||
class SimplePaginator extends NumericPaginator
|
||||
{
|
||||
/**
|
||||
* Get paginated items.
|
||||
*
|
||||
* Get one additional record than the limit. This helps deduce if next page exits.
|
||||
*
|
||||
* @param \Cake\Datasource\QueryInterface $query Query to fetch items.
|
||||
* @param array $data Paging data.
|
||||
* @return \Cake\Datasource\ResultSetInterface
|
||||
*/
|
||||
protected function getItems(QueryInterface $query, array $data): ResultSetInterface
|
||||
{
|
||||
return $query->limit($data['options']['limit'] + 1)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function buildParams(array $data): array
|
||||
{
|
||||
$hasNextPage = false;
|
||||
if ($this->pagingParams['count'] > $data['options']['limit']) {
|
||||
$hasNextPage = true;
|
||||
$this->pagingParams['count'] -= 1;
|
||||
}
|
||||
|
||||
parent::buildParams($data);
|
||||
|
||||
$this->pagingParams['hasNextPage'] = $hasNextPage;
|
||||
|
||||
return $this->pagingParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build paginated result set.
|
||||
*
|
||||
* Since the query fetches an extra record, drop the last record if records
|
||||
* fetched exceeds the limit/per page.
|
||||
*
|
||||
* @param \Cake\Datasource\ResultSetInterface $items
|
||||
* @param array $pagingParams
|
||||
* @return \Cake\Datasource\Paging\PaginatedInterface
|
||||
*/
|
||||
protected function buildPaginated(ResultSetInterface $items, array $pagingParams): PaginatedInterface
|
||||
{
|
||||
if (count($items) > $this->pagingParams['perPage']) {
|
||||
$items = $items->take($this->pagingParams['perPage']);
|
||||
}
|
||||
|
||||
return new PaginatedResultSet($items, $pagingParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple pagination does not perform any count query, so this method returns `null`.
|
||||
*
|
||||
* @param \Cake\Datasource\QueryInterface $query Query instance.
|
||||
* @param array $data Pagination data.
|
||||
* @return int|null
|
||||
*/
|
||||
protected function getCount(QueryInterface $query, array $data): ?int
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
|
||||
* Copyright (c) Cake Software Foundation, Inc. (https://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. (https://cakefoundation.org)
|
||||
* @link https://cakephp.org CakePHP(tm) Project
|
||||
* @since 5.3.0
|
||||
* @license https://opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
namespace Cake\Datasource\Paging;
|
||||
|
||||
/**
|
||||
* Represents a sort field configuration for pagination.
|
||||
*/
|
||||
class SortField
|
||||
{
|
||||
/**
|
||||
* Ascending sort direction
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const ASC = 'asc';
|
||||
|
||||
/**
|
||||
* Descending sort direction
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const DESC = 'desc';
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $field The field name to sort by
|
||||
* @param string|null $defaultDirection The default sort direction
|
||||
* @param bool $locked Whether the sort direction is locked
|
||||
*/
|
||||
public function __construct(
|
||||
protected string $field,
|
||||
protected ?string $defaultDirection = null,
|
||||
protected bool $locked = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sort field with ascending default direction.
|
||||
*
|
||||
* @param string $field The field name to sort by
|
||||
* @param bool $locked Whether the sort direction is locked
|
||||
* @return self
|
||||
*/
|
||||
public static function asc(string $field, bool $locked = false): self
|
||||
{
|
||||
return new self($field, self::ASC, $locked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sort field with descending default direction.
|
||||
*
|
||||
* @param string $field The field name to sort by
|
||||
* @param bool $locked Whether the sort direction is locked
|
||||
* @return self
|
||||
*/
|
||||
public static function desc(string $field, bool $locked = false): self
|
||||
{
|
||||
return new self($field, self::DESC, $locked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the field name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getField(): string
|
||||
{
|
||||
return $this->field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort direction to use.
|
||||
*
|
||||
* @param string $requestedDirection The direction requested by the user
|
||||
* @param bool $directionSpecified Whether a direction was explicitly specified
|
||||
* @return string
|
||||
*/
|
||||
public function getDirection(string $requestedDirection, bool $directionSpecified): string
|
||||
{
|
||||
if ($this->locked) {
|
||||
return $this->defaultDirection ?? self::ASC;
|
||||
}
|
||||
|
||||
if (!$directionSpecified && $this->defaultDirection) {
|
||||
return $this->defaultDirection;
|
||||
}
|
||||
|
||||
if ($this->defaultDirection === static::DESC) {
|
||||
return $requestedDirection === static::DESC ? static::ASC : static::DESC;
|
||||
}
|
||||
|
||||
return $requestedDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the sort direction is locked.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return $this->locked;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
|
||||
* Copyright (c) Cake Software Foundation, Inc. (https://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. (https://cakefoundation.org)
|
||||
* @link https://cakephp.org CakePHP(tm) Project
|
||||
* @since 5.3.0
|
||||
* @license https://opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
namespace Cake\Datasource\Paging;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Builder for creating complete sortable fields configurations.
|
||||
*
|
||||
* Provides interface for building sortable fields with multiple sort keys and fields.
|
||||
* Also handles resolution of sort keys to database ORDER BY clauses.
|
||||
*/
|
||||
class SortableFieldsBuilder
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<\Cake\Datasource\Paging\SortField|string>|string> The sortable fields map being built
|
||||
*/
|
||||
protected array $map = [];
|
||||
|
||||
/**
|
||||
* @var bool Whether this builder represents a simple array format
|
||||
*/
|
||||
protected bool $isSimpleArray = false;
|
||||
|
||||
/**
|
||||
* Create builder from various sortableFields configurations.
|
||||
*
|
||||
* @param \Closure|array<mixed>|null $config The sortableFields configuration
|
||||
* @return static|null Builder instance or null if no config
|
||||
*/
|
||||
public static function create(array|Closure|null $config): ?static
|
||||
{
|
||||
if ($config === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($config instanceof Closure) {
|
||||
return static::fromCallable($config);
|
||||
}
|
||||
|
||||
return static::fromArray($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create builder from array configuration.
|
||||
*
|
||||
* Handles both simple array format (['field1', 'field2']) and
|
||||
* associative map format (['key' => 'field', ...]).
|
||||
*
|
||||
* @param array<mixed> $config Array configuration
|
||||
* @return static
|
||||
*/
|
||||
public static function fromArray(array $config): static
|
||||
{
|
||||
$builder = new static();
|
||||
$hasNumericKeys = false;
|
||||
|
||||
// Check if it's a simple array format
|
||||
foreach ($config as $key => $value) {
|
||||
if (is_int($key)) {
|
||||
$hasNumericKeys = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasNumericKeys) {
|
||||
// Simple or mixed format - convert numeric keys
|
||||
$builder->isSimpleArray = true;
|
||||
foreach ($config as $key => $value) {
|
||||
if (is_int($key) && is_string($value)) {
|
||||
// Numeric key with string value: 'field' becomes 'field' => ['field']
|
||||
$builder->add($value, $value);
|
||||
} else {
|
||||
// String key: use as-is
|
||||
$builder->set($key, $value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Associative map format
|
||||
foreach ($config as $key => $value) {
|
||||
$builder->set($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create builder from callable factory.
|
||||
*
|
||||
* @param \Closure $factory Closure that receives builder and returns it
|
||||
* @return static
|
||||
*/
|
||||
public static function fromCallable(Closure $factory): static
|
||||
{
|
||||
$builder = new static();
|
||||
$builder = $factory($builder);
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a sort key with its associated SortField objects.
|
||||
*
|
||||
* @param string $sortKey The sort key name
|
||||
* @param \Cake\Datasource\Paging\SortField|string ...$fields The sort fields to add
|
||||
* @return $this
|
||||
*/
|
||||
public function add(string $sortKey, SortField|string ...$fields)
|
||||
{
|
||||
if ($fields === []) {
|
||||
// If no fields provided, use the key as the field name
|
||||
$this->map[$sortKey] = [$sortKey];
|
||||
} else {
|
||||
$this->map[$sortKey] = $fields;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a sort key with type-safe validation.
|
||||
*
|
||||
* Internal method used by fromArray() to ensure type safety while preserving
|
||||
* backward compatibility with string and array representations.
|
||||
*
|
||||
* @param string $sortKey The sort key name
|
||||
* @param mixed $value The sort field(s) - can be string, SortField, or array
|
||||
* @return $this
|
||||
*/
|
||||
protected function set(string $sortKey, mixed $value)
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$this->map[$sortKey] = $value;
|
||||
} elseif ($value instanceof SortField) {
|
||||
$this->map[$sortKey] = [$value];
|
||||
} elseif (is_array($value)) {
|
||||
$this->add($sortKey, ...$value);
|
||||
} else {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Invalid sortable field value type for key `%s`. Expected string, array, or SortField, got `%s`.',
|
||||
$sortKey,
|
||||
get_debug_type($value),
|
||||
));
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the complete sortable fields map.
|
||||
*
|
||||
* @return array<string, array<\Cake\Datasource\Paging\SortField|string>|string>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a sort key to its corresponding ORDER BY clause.
|
||||
*
|
||||
* @param string $sortKey The sort key from URL
|
||||
* @param string $direction The requested direction (asc/desc)
|
||||
* @param bool $directionSpecified Whether direction was explicitly specified
|
||||
* @return array<string, string>|null Array of field => direction pairs, or null if invalid
|
||||
*/
|
||||
public function resolve(
|
||||
string $sortKey,
|
||||
string $direction,
|
||||
bool $directionSpecified = true,
|
||||
): ?array {
|
||||
// Check if sort key exists in map
|
||||
if (!isset($this->map[$sortKey])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mapping = $this->map[$sortKey];
|
||||
|
||||
// Empty array means use key as field
|
||||
if ($mapping === []) {
|
||||
return [$sortKey => $direction];
|
||||
}
|
||||
|
||||
return $this->resolveMapping($mapping, $direction, $directionSpecified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a mapping configuration to ORDER BY clause.
|
||||
*
|
||||
* @param mixed $mapping The mapping to resolve
|
||||
* @param string $direction The requested direction
|
||||
* @param bool $directionSpecified Whether direction was explicitly specified
|
||||
* @return array<string, string> Array of field => direction pairs
|
||||
*/
|
||||
protected function resolveMapping(mixed $mapping, string $direction, bool $directionSpecified): array
|
||||
{
|
||||
// Single string: 'name' => 'Users.name'
|
||||
if (is_string($mapping)) {
|
||||
return [$mapping => $direction];
|
||||
}
|
||||
|
||||
// Array of fields/SortField objects
|
||||
if (is_array($mapping)) {
|
||||
return $this->resolveArrayMapping($mapping, $direction, $directionSpecified);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an array mapping to ORDER BY clause.
|
||||
*
|
||||
* @param array<mixed> $fields Array of fields or SortField objects
|
||||
* @param string $direction The requested direction
|
||||
* @param bool $directionSpecified Whether direction was explicitly specified
|
||||
* @return array<string, string> Array of field => direction pairs
|
||||
*/
|
||||
protected function resolveArrayMapping(array $fields, string $direction, bool $directionSpecified): array
|
||||
{
|
||||
$order = [];
|
||||
$shouldInvert = $directionSpecified && $direction === SortField::DESC;
|
||||
|
||||
foreach ($fields as $key => $value) {
|
||||
if ($value instanceof SortField) {
|
||||
// SortField object with locked/default directions
|
||||
$field = $value->getField();
|
||||
$fieldDirection = $value->getDirection($direction, $directionSpecified);
|
||||
$order[$field] = $fieldDirection;
|
||||
} elseif (is_int($key)) {
|
||||
// Numeric array: ['field1', 'field2'] - use requested direction
|
||||
$order[$value] = $direction;
|
||||
} elseif (is_string($value)) {
|
||||
// Associative array with default directions per field
|
||||
// Format: ['field1' => 'ASC', 'field2' => 'DESC']
|
||||
$defaultDirection = strtolower($value);
|
||||
|
||||
if ($shouldInvert) {
|
||||
// Invert the direction when toggling to desc
|
||||
$fieldDirection = $defaultDirection === SortField::ASC ? SortField::DESC : SortField::ASC;
|
||||
} else {
|
||||
// Use default direction (for asc or no direction specified)
|
||||
$fieldDirection = $defaultDirection;
|
||||
}
|
||||
|
||||
$order[$key] = $fieldDirection;
|
||||
} else {
|
||||
// Fallback for other cases
|
||||
$order[$key] = $direction;
|
||||
}
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user