From 1d06b064c84ee0c7b102701b463dc52b659a8974 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 9 Sep 2025 14:49:59 +0200 Subject: [PATCH 1/7] Paginator sortMap. --- src/Datasource/Paging/NumericPaginator.php | 120 +++++++++- .../Paging/NumericPaginatorTest.php | 223 ++++++++++++++++++ .../Datasource/Paging/PaginatorTestTrait.php | 13 + 3 files changed, 350 insertions(+), 6 deletions(-) diff --git a/src/Datasource/Paging/NumericPaginator.php b/src/Datasource/Paging/NumericPaginator.php index b65120795a0..13e3264545c 100644 --- a/src/Datasource/Paging/NumericPaginator.php +++ b/src/Datasource/Paging/NumericPaginator.php @@ -49,6 +49,22 @@ class NumericPaginator implements PaginatorInterface * sorting on either associated columns or calculated fields then you will * have to explicitly specify them (along with other fields). Using an empty * array will disable sorting alltogether. + * - `sortMap` - A map of sort keys to their corresponding database fields. Allows + * creating friendly sort keys that map to one or more actual fields. When defined, + * only the mapped keys will be sortable. Supports simple mapping, multi-column + * sorting, and fixed direction sorting. You can also use numeric arrays for 1:1 + * mappings where the field name is the same as the sort key. Example: + * ``` + * 'sortMap' => [ + * 'name' => 'Users.name', // Simple mapping + * 'title', // Shorthand for 'title' => 'title' + * 'modified' => ['modified', 'name'], // Multi-column + * 'popularity' => [ // Fixed direction + * 'score', + * 'created' => 'desc' + * ] + * ] + * ``` * - `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 @@ -63,6 +79,7 @@ class NumericPaginator implements PaginatorInterface 'maxLimit' => 100, 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', 'scope' => null, ]; @@ -548,12 +565,30 @@ protected function validateSort(RepositoryInterface $object, array $options): ar $direction = 'asc'; } - $order = isset($options['order']) && is_array($options['order']) ? $options['order'] : []; - if ($order && $options['sort'] && !str_contains($options['sort'], '.')) { - $order = $this->_removeAliases($order, $object->getAlias()); - } + // Check sortMap first for mapped sorting + if (isset($options['sortMap'])) { + $mappedOrder = $this->resolveSortMapping($options['sort'], $options['sortMap'], $direction); + if ($mappedOrder !== null) { + // Use mapped order and merge with existing order + $existingOrder = isset($options['order']) && is_array($options['order']) ? $options['order'] : []; + $options['order'] = $mappedOrder + $existingOrder; + } else { + // Sort key not in sortMap, clear sort + $options['order'] = []; + $options['sort'] = null; + unset($options['direction']); + + return $options; + } + } else { + // No sortMap, use traditional sorting + $order = isset($options['order']) && is_array($options['order']) ? $options['order'] : []; + if ($order && $options['sort'] && !str_contains($options['sort'], '.')) { + $order = $this->_removeAliases($order, $object->getAlias()); + } - $options['order'] = [$options['sort'] => $direction] + $order; + $options['order'] = [$options['sort'] => $direction] + $order; + } } else { $options['sort'] = null; } @@ -567,7 +602,12 @@ protected function validateSort(RepositoryInterface $object, array $options): ar } $sortAllowed = false; - if (isset($options['sortableFields'])) { + + // Skip sortableFields check if sortMap is being used + if (isset($options['sortMap'])) { + // When sortMap is used, we've already validated the sort key + $sortAllowed = true; + } elseif (isset($options['sortableFields'])) { $field = key($options['order']); $sortAllowed = in_array($field, $options['sortableFields'], true); if (!$sortAllowed) { @@ -668,6 +708,74 @@ protected function _prefix(RepositoryInterface $object, array $order, bool $allo return $tableOrder; } + /** + * Resolves sort mapping for a given sort key. + * + * Takes a sort key and resolves it using the sortMap configuration. + * Supports simple mapping, multi-column sorting, and fixed direction sorting. + * + * @param string $sortKey The sort key to resolve + * @param array|null $sortMap The sort mapping configuration + * @param string $direction The requested sort direction + * @return array|null Returns resolved order array or null if key not found + */ + protected function resolveSortMapping(string $sortKey, ?array $sortMap, string $direction): ?array + { + if ($sortMap === null) { + return null; + } + + // Check for direct mapping first + if (isset($sortMap[$sortKey])) { + $mapping = $sortMap[$sortKey]; + } else { + // Check for shorthand numeric array syntax: ['name'] means 'name' => 'name' + // We check if the sortKey exists as a value in numeric indices + $found = false; + foreach ($sortMap as $key => $value) { + if (is_int($key) && is_string($value) && $value === $sortKey) { + $found = true; + break; + } + } + if ($found) { + $order = [$sortKey => $direction]; + + return $order; + } + + return null; + } + + $order = []; + + // Simple string mapping: 'name' => 'Users.name' + if (is_string($mapping)) { + $order[$mapping] = $direction; + + return $order; + } + + // Array mapping (multi-column or fixed direction) + if (is_array($mapping)) { + foreach ($mapping as $key => $value) { + if (is_int($key)) { + // Indexed array: field uses querystring direction + // e.g., ['modified', 'name'] + $order[$value] = $direction; + } else { + // Associative array: field has fixed direction + // e.g., ['created' => 'desc'] + $order[$key] = $value; + } + } + + return $order; + } + + return null; + } + /** * Check the limit parameter and ensure it's within the maxLimit bounds. * diff --git a/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php index 1c2c903f8d9..73f2f9ff45c 100644 --- a/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php +++ b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php @@ -179,4 +179,227 @@ function (): void { }, ); } + + /** + * Test sortMap with simple 1:1 mapping + */ + public function testSortMapSimpleMapping(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortMap' => [ + 'name' => 'PaginatorPosts.title', + 'content' => 'PaginatorPosts.body', + ], + ]; + + // Test sorting by mapped key 'name' + $params = ['sort' => 'name', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('name', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); + + // Test sorting by mapped key 'content' with desc direction + $params = ['sort' => 'content', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('content', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.body' => 'desc'], $pagingParams['completeSort']); + } + + /** + * Test sortMap with shorthand numeric array syntax for 1:1 mapping + */ + public function testSortMapShorthandSyntax(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortMap' => [ + 'title', + 'body', + 'name' => 'PaginatorPosts.title', + ], + ]; + + // Test sorting by shorthand mapped key 'title' + $params = ['sort' => 'title', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + // Shorthand fields still get prefixed with table name for actual query + $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); + + // Test sorting by shorthand mapped key 'body' + $params = ['sort' => 'body', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('body', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + // Shorthand fields still get prefixed with table name for actual query + $this->assertEquals(['PaginatorPosts.body' => 'desc'], $pagingParams['completeSort']); + + // Test that regular mapping still works alongside shorthand + $params = ['sort' => 'name', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('name', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); + } + + /** + * Test sortMap with multi-column sorting + */ + public function testSortMapMultiColumnSorting(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortMap' => [ + 'titleauthor' => ['PaginatorPosts.title', 'PaginatorPosts.author_id'], + 'relevance' => ['PaginatorPosts.id', 'PaginatorPosts.body'], + ], + ]; + + // Test multi-column sorting with 'titleauthor' + $params = ['sort' => 'titleauthor', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('titleauthor', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals([ + 'PaginatorPosts.title' => 'desc', + 'PaginatorPosts.author_id' => 'desc', + ], $pagingParams['completeSort']); + + // Test multi-column sorting with 'relevance' + $params = ['sort' => 'relevance', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('relevance', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals([ + 'PaginatorPosts.id' => 'asc', + 'PaginatorPosts.body' => 'asc', + ], $pagingParams['completeSort']); + } + + /** + * Test sortMap with fixed direction sorting + */ + public function testSortMapFixedDirectionSorting(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortMap' => [ + 'fresh' => [ + 'PaginatorPosts.title', + 'PaginatorPosts.body' => 'desc', + ], + 'popularity' => [ + 'PaginatorPosts.id' => 'desc', + 'PaginatorPosts.author_id' => 'asc', + ], + ], + ]; + + // Test 'fresh' with mixed directions (querystring direction for title, fixed desc for body) + $params = ['sort' => 'fresh', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('fresh', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); // The first non-fixed field's direction + $this->assertEquals([ + 'PaginatorPosts.title' => 'asc', // Uses querystring direction + 'PaginatorPosts.body' => 'desc', // Fixed direction + ], $pagingParams['completeSort']); + + // Test 'popularity' with all fixed directions + $params = ['sort' => 'popularity', 'direction' => 'asc']; // Direction should be ignored + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('popularity', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.id' => 'desc', + 'PaginatorPosts.author_id' => 'asc', + ], $pagingParams['completeSort']); + } + + /** + * Test that unmapped keys are rejected when sortMap is defined + */ + public function testSortMapRejectsUnmappedKeys(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortMap' => [ + 'name' => 'PaginatorPosts.title', + ], + ]; + + // Try to sort by unmapped field + $params = ['sort' => 'body', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Sort should be cleared as it's not in sortMap + $this->assertNull($pagingParams['sort']); + $this->assertNull($pagingParams['direction']); + $this->assertEquals([], $pagingParams['completeSort']); + } + + /** + * Test backward compatibility when sortMap is not configured + */ + public function testBackwardCompatibilityWithoutSortMap(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + + // Test without sortMap - should work as before + $params = ['sort' => 'title', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); + } + + /** + * Test sortMap with association sorting + */ + public function testSortMapWithAssociations(): void + { + $table = $this->getTableLocator()->get('Articles'); + // Association is already set up in the Articles table + + $settings = [ + 'sortMap' => [ + 'author' => 'Authors.name', + 'author_article' => ['Authors.name', 'Articles.title'], + ], + ]; + + // Test association field mapping + $params = ['sort' => 'author', 'direction' => 'asc']; + $query = $table->find()->contain(['Authors']); + $result = $this->Paginator->paginate($query, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('author', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals(['Authors.name' => 'asc'], $pagingParams['completeSort']); + } } diff --git a/tests/TestCase/Datasource/Paging/PaginatorTestTrait.php b/tests/TestCase/Datasource/Paging/PaginatorTestTrait.php index 4594c0ecea1..41e41f083f5 100644 --- a/tests/TestCase/Datasource/Paging/PaginatorTestTrait.php +++ b/tests/TestCase/Datasource/Paging/PaginatorTestTrait.php @@ -295,6 +295,7 @@ public function testMergeOptionsModelSpecific(): void $result = $this->Paginator->mergeOptions([], $defaults); $this->assertEquals($settings + [ 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', ], $result); @@ -306,6 +307,7 @@ public function testMergeOptionsModelSpecific(): void 'maxLimit' => 50, 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', 'scope' => null, ]; @@ -341,6 +343,7 @@ public function testMergeOptionsCustomScope(): void 'finder' => 'myCustomFind', 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'scope' => null, ]; $this->assertEquals($expected, $result); @@ -362,6 +365,7 @@ public function testMergeOptionsCustomScope(): void 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'scope' => 'nonexistent', 'sortableFields' => null, + 'sortMap' => null, ]; $this->assertEquals($expected, $result); @@ -382,6 +386,7 @@ public function testMergeOptionsCustomScope(): void 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'scope' => 'scope', 'sortableFields' => null, + 'sortMap' => null, ]; $this->assertEquals($expected, $result); } @@ -410,6 +415,7 @@ public function testMergeOptionsCustomFindKey(): void 'finder' => 'myCustomFind', 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'scope' => null, ]; $this->assertEquals($expected, $result); @@ -437,6 +443,7 @@ public function testMergeOptionsQueryString(): void 'maxLimit' => 100, 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', 'scope' => null, ]; @@ -469,6 +476,7 @@ public function testMergeOptionsDefaultAllowedParameters(): void 'maxLimit' => 100, 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', 'scope' => null, ]; @@ -504,6 +512,7 @@ public function testMergeOptionsExtraAllowedParameters(): void 'fields' => ['bad.stuff'], 'allowedParameters' => ['limit', 'sort', 'page', 'direction', 'fields'], 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', 'scope' => null, ]; @@ -528,6 +537,7 @@ public function testMergeOptionsMaxLimit(): void 'paramType' => 'named', 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', 'scope' => null, ]; @@ -546,6 +556,7 @@ public function testMergeOptionsMaxLimit(): void 'paramType' => 'named', 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', 'scope' => null, ]; @@ -576,6 +587,7 @@ public function testGetDefaultMaxLimit(): void ], 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', 'scope' => null, ]; @@ -600,6 +612,7 @@ public function testGetDefaultMaxLimit(): void ], 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], 'sortableFields' => null, + 'sortMap' => null, 'finder' => 'all', 'scope' => null, ]; From 2a5410571343fd9583862778c8bc576627bd61e3 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 10 Sep 2025 15:16:58 +0200 Subject: [PATCH 2/7] Showcasing combined sort-dir. --- src/Datasource/Paging/NumericPaginator.php | 17 +++- src/View/Helper/PaginatorHelper.php | 42 +++++++-- .../Paging/NumericPaginatorTest.php | 88 +++++++++++++++++++ .../View/Helper/PaginatorHelperTest.php | 76 ++++++++++++++++ 4 files changed, 215 insertions(+), 8 deletions(-) diff --git a/src/Datasource/Paging/NumericPaginator.php b/src/Datasource/Paging/NumericPaginator.php index 13e3264545c..c657e7647a6 100644 --- a/src/Datasource/Paging/NumericPaginator.php +++ b/src/Datasource/Paging/NumericPaginator.php @@ -535,6 +535,13 @@ protected function getDefaults(string $alias, array $settings): array * also be sanitized. Lastly sort + direction keys will be converted into * the model friendly order key. * + * Supports two formats for sort parameters: + * - Traditional: `?sort=title&direction=asc` + * - Combined: `?sort=title-asc` or `?sort=title-desc` + * + * The combined format merges the field and direction into a single parameter, + * making URLs cleaner and more RESTful. Both formats work with sortMap. + * * 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. @@ -558,9 +565,17 @@ protected function validateSort(RepositoryInterface $object, array $options): ar { if (isset($options['sort'])) { $direction = null; - if (isset($options['direction'])) { + $sortField = $options['sort']; + + // Check for combined sort-direction format (e.g., 'title-asc' or 'title-desc') + if (preg_match('/^(.+)-(asc|desc)$/i', $sortField, $matches)) { + $sortField = $matches[1]; + $direction = strtolower($matches[2]); + $options['sort'] = $sortField; + } elseif (isset($options['direction'])) { $direction = strtolower($options['direction']); } + if (!in_array($direction, ['asc', 'desc'], true)) { $direction = 'asc'; } diff --git a/src/View/Helper/PaginatorHelper.php b/src/View/Helper/PaginatorHelper.php index d943eb36b01..fe15a5a349f 100644 --- a/src/View/Helper/PaginatorHelper.php +++ b/src/View/Helper/PaginatorHelper.php @@ -64,6 +64,8 @@ class PaginatorHelper extends Helper * - `routePlaceholders` An array specifying which paging params should be * passed as route placeholders instead of query string parameters. The array * can have values `'sort'`, `'direction'`, `'page'`. + * - `sortFormat` Format for sort parameters. Can be 'separate' (default) for + * `?sort=field&direction=asc` or 'combined' for `?sort=field-asc`. * * Templates: the templates used by this class * @@ -71,7 +73,9 @@ class PaginatorHelper extends Helper */ protected array $_defaultConfig = [ 'params' => [], - 'options' => [], + 'options' => [ + 'sortFormat' => 'separate', + ], 'templates' => [ 'nextActive' => '', 'nextDisabled' => '', @@ -433,7 +437,15 @@ public function sort(string $key, array|string|null $title = null, array $option $title = $title[$dir]; } - $paging = ['sort' => $key, 'direction' => $dir, 'page' => 1]; + // Check if we should use combined format + $sortFormat = $this->getConfig('options.sortFormat', 'separate'); + if ($sortFormat === 'combined') { + // Use combined format: field-asc or field-desc + $paging = ['sort' => $key . '-' . $dir, 'page' => 1]; + } else { + // Use traditional separate format + $paging = ['sort' => $key, 'direction' => $dir, 'page' => 1]; + } $vars = [ 'text' => $options['escape'] ? h($title) : $title, @@ -501,17 +513,33 @@ public function generateUrlParams(array $options = [], array $url = []): array $paging['sortDefault'] = $this->_removeAlias($paging['sortDefault'], $this->param('alias')); } - $options += array_intersect_key( - $paging, - ['page' => null, 'limit' => null, 'sort' => null, 'direction' => null], - ); + // Check if we're using combined format in the options + $sortFormat = $this->getConfig('options.sortFormat', 'separate'); + $hasCombinedSort = isset($options['sort']) && preg_match('/-(asc|desc)$/i', $options['sort']); + + if ($sortFormat === 'combined' || $hasCombinedSort) { + // Don't include separate direction parameter when using combined format + $options += array_intersect_key( + $paging, + ['page' => null, 'limit' => null, 'sort' => null], + ); + // Remove any separate direction parameter + unset($options['direction']); + } else { + $options += array_intersect_key( + $paging, + ['page' => null, 'limit' => null, 'sort' => null, 'direction' => null], + ); + } if (!empty($options['page']) && $options['page'] === 1) { $options['page'] = null; } if ( - isset($paging['sortDefault'], $paging['directionDefault'], $options['sort'], $options['direction']) + isset($paging['sortDefault'], $paging['directionDefault'], $options['sort']) + && !$hasCombinedSort + && isset($options['direction']) && $options['sort'] === $paging['sortDefault'] && strtolower($options['direction']) === strtolower($paging['directionDefault']) ) { diff --git a/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php index 73f2f9ff45c..23173a10d8e 100644 --- a/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php +++ b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php @@ -377,6 +377,94 @@ public function testBackwardCompatibilityWithoutSortMap(): void $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); } + /** + * Test combined sort-direction parameter format (e.g., 'title-asc') + */ + public function testCombinedSortDirectionFormat(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + + // Test ascending with combined format + $params = ['sort' => 'title-asc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); + + // Test descending with combined format + $params = ['sort' => 'body-desc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('body', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.body' => 'desc'], $pagingParams['completeSort']); + + // Test that traditional format still works + $params = ['sort' => 'title', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); + + // Test combined format with hyphenated field names + $params = ['sort' => 'author_id-desc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('author_id', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.author_id' => 'desc'], $pagingParams['completeSort']); + } + + /** + * Test combined sort format with sortMap + */ + public function testCombinedSortFormatWithSortMap(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortMap' => [ + 'name' => 'PaginatorPosts.title', + 'content' => 'PaginatorPosts.body', + 'newest' => ['PaginatorPosts.id' => 'desc', 'PaginatorPosts.title'], + ], + ]; + + // Test simple mapping with combined format + $params = ['sort' => 'name-desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('name', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); + + // Test that unmapped fields with combined format are still rejected + $params = ['sort' => 'unmapped-asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertNull($pagingParams['sort']); + $this->assertNull($pagingParams['direction']); + $this->assertEquals([], $pagingParams['completeSort']); + + // Test multi-field mapping with combined format + $params = ['sort' => 'newest-asc']; // Direction should apply to non-fixed fields + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.id' => 'desc', // Fixed direction + 'PaginatorPosts.title' => 'asc', // Uses combined format direction + ], $pagingParams['completeSort']); + } + /** * Test sortMap with association sorting */ diff --git a/tests/TestCase/View/Helper/PaginatorHelperTest.php b/tests/TestCase/View/Helper/PaginatorHelperTest.php index c63fdcf3429..fcea1f5f833 100644 --- a/tests/TestCase/View/Helper/PaginatorHelperTest.php +++ b/tests/TestCase/View/Helper/PaginatorHelperTest.php @@ -616,6 +616,82 @@ public function testSortAdminLinks(): void $this->assertHtml($expected, $result); } + /** + * Test sort links with combined format (field-direction) + */ + public function testSortLinksCombinedFormat(): void + { + $request = new ServerRequest([ + 'url' => '/accounts/', + 'params' => [ + 'plugin' => null, 'controller' => 'Accounts', 'action' => 'index', 'pass' => [], + ], + 'base' => '', + 'webroot' => '/', + ]); + Router::setRequest($request); + + // Configure to use combined format + $this->Paginator->setConfig('options.sortFormat', 'combined'); + + $this->setPaginatedResult([ + 'currentPage' => 1, + 'count' => 9, + 'totalCount' => 62, + 'hasPrevPage' => false, + 'hasNextPage' => true, + 'pageCount' => 7, + 'sort' => 'date', + 'direction' => 'asc', + ], false); + + // Test generating sort link with combined format + $result = $this->Paginator->sort('title'); + $expected = [ + 'a' => ['href' => '/Accounts/index?sort=title-asc'], + 'Title', + '/a', + ]; + $this->assertHtml($expected, $result); + + // Test with current sort field - should toggle direction + $result = $this->Paginator->sort('date'); + $expected = [ + 'a' => ['href' => '/Accounts/index?sort=date-desc', 'class' => 'asc'], + 'Date', + '/a', + ]; + $this->assertHtml($expected, $result); + + // Test with custom title + $result = $this->Paginator->sort('title', 'Custom Title'); + $expected = [ + 'a' => ['href' => '/Accounts/index?sort=title-asc'], + 'Custom Title', + '/a', + ]; + $this->assertHtml($expected, $result); + + // Test with locked direction + $result = $this->Paginator->sort('price', null, ['direction' => 'desc', 'lock' => true]); + $expected = [ + 'a' => ['href' => '/Accounts/index?sort=price-desc'], + 'Price', + '/a', + ]; + $this->assertHtml($expected, $result); + + // Reset to separate format and verify it still works + $this->Paginator->setConfig('options.sortFormat', 'separate'); + $result = $this->Paginator->sort('name'); + $expected = [ + 'a' => ['href' => '/Accounts/index?sort=name&direction=asc'], + 'Name', + '/a', + ]; + $this->assertHtml($expected, $result); + } + /** * Test that generated URLs work without sort defined within the request */ From 7eb19a3604024a3218d18bd6274614c8ed63fc39 Mon Sep 17 00:00:00 2001 From: mscherer Date: Fri, 12 Sep 2025 14:15:15 +0200 Subject: [PATCH 3/7] Use ! for locking. --- src/Datasource/Paging/NumericPaginator.php | 28 ++++- .../Paging/NumericPaginatorTest.php | 119 ++++++++++++++++-- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/src/Datasource/Paging/NumericPaginator.php b/src/Datasource/Paging/NumericPaginator.php index c657e7647a6..904a420e625 100644 --- a/src/Datasource/Paging/NumericPaginator.php +++ b/src/Datasource/Paging/NumericPaginator.php @@ -568,12 +568,15 @@ protected function validateSort(RepositoryInterface $object, array $options): ar $sortField = $options['sort']; // Check for combined sort-direction format (e.g., 'title-asc' or 'title-desc') + $directionSpecified = false; if (preg_match('/^(.+)-(asc|desc)$/i', $sortField, $matches)) { $sortField = $matches[1]; $direction = strtolower($matches[2]); $options['sort'] = $sortField; + $directionSpecified = true; } elseif (isset($options['direction'])) { $direction = strtolower($options['direction']); + $directionSpecified = true; } if (!in_array($direction, ['asc', 'desc'], true)) { @@ -582,7 +585,7 @@ protected function validateSort(RepositoryInterface $object, array $options): ar // Check sortMap first for mapped sorting if (isset($options['sortMap'])) { - $mappedOrder = $this->resolveSortMapping($options['sort'], $options['sortMap'], $direction); + $mappedOrder = $this->resolveSortMapping($options['sort'], $options['sortMap'], $direction, $directionSpecified); if ($mappedOrder !== null) { // Use mapped order and merge with existing order $existingOrder = isset($options['order']) && is_array($options['order']) ? $options['order'] : []; @@ -732,9 +735,10 @@ protected function _prefix(RepositoryInterface $object, array $order, bool $allo * @param string $sortKey The sort key to resolve * @param array|null $sortMap The sort mapping configuration * @param string $direction The requested sort direction + * @param bool $directionSpecified Whether direction was explicitly specified * @return array|null Returns resolved order array or null if key not found */ - protected function resolveSortMapping(string $sortKey, ?array $sortMap, string $direction): ?array + protected function resolveSortMapping(string $sortKey, ?array $sortMap, string $direction, bool $directionSpecified = true): ?array { if ($sortMap === null) { return null; @@ -771,7 +775,7 @@ protected function resolveSortMapping(string $sortKey, ?array $sortMap, string $ return $order; } - // Array mapping (multi-column or fixed direction) + // Array mapping (multi-column with default or locked directions) if (is_array($mapping)) { foreach ($mapping as $key => $value) { if (is_int($key)) { @@ -779,9 +783,21 @@ protected function resolveSortMapping(string $sortKey, ?array $sortMap, string $ // e.g., ['modified', 'name'] $order[$value] = $direction; } else { - // Associative array: field has fixed direction - // e.g., ['created' => 'desc'] - $order[$key] = $value; + // Associative array: check for locked (!) or default direction + if (str_ends_with($value, '!')) { + // Locked direction (ends with !): always use specified direction + // e.g., ['created' => 'desc!'] always sorts desc + $order[$key] = rtrim($value, '!'); + } else { + // Default direction that can be toggled + if (!$directionSpecified) { + // No direction specified, use the default + $order[$key] = $value; + } else { + // Direction specified, use it for all toggleable fields + $order[$key] = $direction; + } + } } } diff --git a/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php index 23173a10d8e..a7a42d7e5b9 100644 --- a/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php +++ b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php @@ -304,28 +304,28 @@ public function testSortMapFixedDirectionSorting(): void 'sortMap' => [ 'fresh' => [ 'PaginatorPosts.title', - 'PaginatorPosts.body' => 'desc', + 'PaginatorPosts.body' => 'desc!', // Locked to desc with ! ], 'popularity' => [ - 'PaginatorPosts.id' => 'desc', - 'PaginatorPosts.author_id' => 'asc', + 'PaginatorPosts.id' => 'desc!', // Locked to desc with ! + 'PaginatorPosts.author_id' => 'asc!', // Locked to asc with ! ], ], ]; - // Test 'fresh' with mixed directions (querystring direction for title, fixed desc for body) + // Test 'fresh' with mixed directions (querystring direction for title, locked desc for body) $params = ['sort' => 'fresh', 'direction' => 'asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); $this->assertEquals('fresh', $pagingParams['sort']); - $this->assertEquals('asc', $pagingParams['direction']); // The first non-fixed field's direction + $this->assertEquals('asc', $pagingParams['direction']); // The first non-locked field's direction $this->assertEquals([ 'PaginatorPosts.title' => 'asc', // Uses querystring direction - 'PaginatorPosts.body' => 'desc', // Fixed direction + 'PaginatorPosts.body' => 'desc', // Locked direction ], $pagingParams['completeSort']); - // Test 'popularity' with all fixed directions + // Test 'popularity' with all locked directions $params = ['sort' => 'popularity', 'direction' => 'asc']; // Direction should be ignored $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -337,6 +337,77 @@ public function testSortMapFixedDirectionSorting(): void ], $pagingParams['completeSort']); } + /** + * Test sortMap with toggleable default directions and locked directions + */ + public function testSortMapToggleableAndLockedDirections(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortMap' => [ + 'custom' => [ + 'PaginatorPosts.title' => 'asc', // Default asc, can toggle + 'PaginatorPosts.body' => 'desc', // Default desc, can toggle + ], + 'locked' => [ + 'PaginatorPosts.id' => 'desc!', // Locked to desc + 'PaginatorPosts.author_id' => 'asc', // Default asc, can toggle + ], + ], + ]; + + // Test 'custom' with default directions (no direction in query) + $params = ['sort' => 'custom']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.title' => 'asc', // Uses default + 'PaginatorPosts.body' => 'desc', // Uses default + ], $pagingParams['completeSort']); + + // Test 'custom' with asc direction (should use defaults) + $params = ['sort' => 'custom', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals([ + 'PaginatorPosts.title' => 'asc', // Default was asc, stays asc + 'PaginatorPosts.body' => 'asc', // Default was desc, flips to asc + ], $pagingParams['completeSort']); + + // Test 'custom' with desc direction (should flip defaults) + $params = ['sort' => 'custom', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals([ + 'PaginatorPosts.title' => 'desc', // Default was asc, flips to desc + 'PaginatorPosts.body' => 'desc', // Default was desc, stays desc + ], $pagingParams['completeSort']); + + // Test 'locked' with asc direction + $params = ['sort' => 'locked', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals([ + 'PaginatorPosts.id' => 'desc', // Locked, never changes + 'PaginatorPosts.author_id' => 'asc', // Default asc, stays asc + ], $pagingParams['completeSort']); + + // Test 'locked' with desc direction + $params = ['sort' => 'locked', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals([ + 'PaginatorPosts.id' => 'desc', // Locked, never changes + 'PaginatorPosts.author_id' => 'desc', // Default asc, flips to desc + ], $pagingParams['completeSort']); + } + /** * Test that unmapped keys are rejected when sortMap is defined */ @@ -431,7 +502,11 @@ public function testCombinedSortFormatWithSortMap(): void 'sortMap' => [ 'name' => 'PaginatorPosts.title', 'content' => 'PaginatorPosts.body', - 'newest' => ['PaginatorPosts.id' => 'desc', 'PaginatorPosts.title'], + 'newest' => ['PaginatorPosts.id' => 'desc!', 'PaginatorPosts.title'], + 'custom' => [ + 'PaginatorPosts.author_id' => 'asc', // Toggleable default + 'PaginatorPosts.body' => 'desc', // Toggleable default + ], ], ]; @@ -453,16 +528,38 @@ public function testCombinedSortFormatWithSortMap(): void $this->assertNull($pagingParams['direction']); $this->assertEquals([], $pagingParams['completeSort']); - // Test multi-field mapping with combined format - $params = ['sort' => 'newest-asc']; // Direction should apply to non-fixed fields + // Test multi-field mapping with combined format (locked field) + $params = ['sort' => 'newest-asc']; // Direction should apply to non-locked fields $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); $this->assertEquals('newest', $pagingParams['sort']); $this->assertEquals([ - 'PaginatorPosts.id' => 'desc', // Fixed direction + 'PaginatorPosts.id' => 'desc', // Locked direction (!) 'PaginatorPosts.title' => 'asc', // Uses combined format direction ], $pagingParams['completeSort']); + + // Test toggleable defaults with combined format - asc + $params = ['sort' => 'custom-asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.author_id' => 'asc', // Default asc, stays asc + 'PaginatorPosts.body' => 'asc', // Default desc, flips to asc + ], $pagingParams['completeSort']); + + // Test toggleable defaults with combined format - desc + $params = ['sort' => 'custom-desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.author_id' => 'desc', // Default asc, flips to desc + 'PaginatorPosts.body' => 'desc', // Default desc, stays desc + ], $pagingParams['completeSort']); } /** From b548754b1ab16e90f7aca842aa7f37ad739beaad Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 14 Sep 2025 12:49:44 +0200 Subject: [PATCH 4/7] Add factory and object approach. --- src/Datasource/Paging/NumericPaginator.php | 29 +- src/Datasource/Paging/SortField.php | 140 +++++ src/Datasource/Paging/SortFieldFactory.php | 128 +++++ .../Paging/NumericPaginatorSortFieldTest.php | 484 ++++++++++++++++++ .../Paging/NumericPaginatorTest.php | 92 ++-- .../Paging/SortFieldFactoryTest.php | 197 +++++++ .../Datasource/Paging/SortFieldTest.php | 190 +++++++ 7 files changed, 1209 insertions(+), 51 deletions(-) create mode 100644 src/Datasource/Paging/SortField.php create mode 100644 src/Datasource/Paging/SortFieldFactory.php create mode 100644 tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php create mode 100644 tests/TestCase/Datasource/Paging/SortFieldFactoryTest.php create mode 100644 tests/TestCase/Datasource/Paging/SortFieldTest.php diff --git a/src/Datasource/Paging/NumericPaginator.php b/src/Datasource/Paging/NumericPaginator.php index 904a420e625..cf75e4be12f 100644 --- a/src/Datasource/Paging/NumericPaginator.php +++ b/src/Datasource/Paging/NumericPaginator.php @@ -49,6 +49,8 @@ class NumericPaginator implements PaginatorInterface * sorting on either associated columns or calculated fields then you will * have to explicitly specify them (along with other fields). Using an empty * array will disable sorting alltogether. + * + * @deprecated 5.3.0 Use `sortMap` with SortField objects instead for more flexible sorting configuration. * - `sortMap` - A map of sort keys to their corresponding database fields. Allows * creating friendly sort keys that map to one or more actual fields. When defined, * only the mapped keys will be sortable. Supports simple mapping, multi-column @@ -70,7 +72,6 @@ class NumericPaginator implements PaginatorInterface * 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 */ protected array $_defaultConfig = [ @@ -585,7 +586,12 @@ protected function validateSort(RepositoryInterface $object, array $options): ar // Check sortMap first for mapped sorting if (isset($options['sortMap'])) { - $mappedOrder = $this->resolveSortMapping($options['sort'], $options['sortMap'], $direction, $directionSpecified); + $mappedOrder = $this->resolveSortMapping( + $options['sort'], + $options['sortMap'], + $direction, + $directionSpecified, + ); if ($mappedOrder !== null) { // Use mapped order and merge with existing order $existingOrder = isset($options['order']) && is_array($options['order']) ? $options['order'] : []; @@ -626,6 +632,10 @@ protected function validateSort(RepositoryInterface $object, array $options): ar // When sortMap is used, we've already validated the sort key $sortAllowed = true; } elseif (isset($options['sortableFields'])) { + triggerWarning( + 'The `sortableFields` configuration option is deprecated as of 5.3.0. ' . + 'Use `sortMap` with SortField objects instead for more flexible sorting configuration.', + ); $field = key($options['order']); $sortAllowed = in_array($field, $options['sortableFields'], true); if (!$sortAllowed) { @@ -738,8 +748,12 @@ protected function _prefix(RepositoryInterface $object, array $order, bool $allo * @param bool $directionSpecified Whether direction was explicitly specified * @return array|null Returns resolved order array or null if key not found */ - protected function resolveSortMapping(string $sortKey, ?array $sortMap, string $direction, bool $directionSpecified = true): ?array - { + protected function resolveSortMapping( + string $sortKey, + ?array $sortMap, + string $direction, + bool $directionSpecified = true, + ): ?array { if ($sortMap === null) { return null; } @@ -778,7 +792,12 @@ protected function resolveSortMapping(string $sortKey, ?array $sortMap, string $ // Array mapping (multi-column with default or locked directions) if (is_array($mapping)) { foreach ($mapping as $key => $value) { - if (is_int($key)) { + // Handle SortField objects + if ($value instanceof SortField) { + $field = $value->getField(); + $fieldDirection = $value->getDirection($direction, $directionSpecified); + $order[$field] = $fieldDirection; + } elseif (is_int($key)) { // Indexed array: field uses querystring direction // e.g., ['modified', 'name'] $order[$value] = $direction; diff --git a/src/Datasource/Paging/SortField.php b/src/Datasource/Paging/SortField.php new file mode 100644 index 00000000000..071228540ee --- /dev/null +++ b/src/Datasource/Paging/SortField.php @@ -0,0 +1,140 @@ +field = $field; + $this->defaultDirection = $defaultDirection; + $this->locked = $locked; + } + + /** + * Create a sort field with ascending default direction. + * + * @param string $field The field name to sort by + * @return self + */ + public static function asc(string $field): self + { + return new self($field, self::ASC, false); + } + + /** + * Create a sort field with descending default direction. + * + * @param string $field The field name to sort by + * @return self + */ + public static function desc(string $field): self + { + return new self($field, self::DESC, false); + } + + /** + * Create a locked sort field with a fixed direction. + * + * @param string $field The field name to sort by + * @param string $direction The fixed sort direction + * @return self + */ + public static function locked(string $field, string $direction): self + { + return new self($field, $direction, true); + } + + /** + * 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 ?? static::ASC; + } + + if (!$directionSpecified && $this->defaultDirection) { + return $this->defaultDirection; + } + + return $requestedDirection; + } + + /** + * Check if the sort direction is locked. + * + * @return bool + */ + public function isLocked(): bool + { + return $this->locked; + } +} diff --git a/src/Datasource/Paging/SortFieldFactory.php b/src/Datasource/Paging/SortFieldFactory.php new file mode 100644 index 00000000000..b8a91126d2f --- /dev/null +++ b/src/Datasource/Paging/SortFieldFactory.php @@ -0,0 +1,128 @@ + The sort fields being built + */ + protected array $fields = []; + + /** + * Create a new factory instance. + * + * @return self + */ + public static function create(): self + { + return new self(); + } + + /** + * Add a field with ascending default direction. + * + * @param string $field The field name + * @return $this + */ + public function asc(string $field) + { + $this->fields[] = SortField::asc($field); + + return $this; + } + + /** + * Add a field with descending default direction. + * + * @param string $field The field name + * @return $this + */ + public function desc(string $field) + { + $this->fields[] = SortField::desc($field); + + return $this; + } + + /** + * Add a locked field with fixed direction. + * + * @param string $field The field name + * @param string $direction The fixed direction ('asc' or 'desc') + * @return $this + */ + public function locked(string $field, string $direction) + { + $this->fields[] = SortField::locked($field, $direction); + + return $this; + } + + /** + * Add a custom SortField instance. + * + * @param \Cake\Datasource\Paging\SortField $sortField The sort field to add + * @return $this + */ + public function add(SortField $sortField) + { + $this->fields[] = $sortField; + + return $this; + } + + /** + * Add a toggleable field with optional default direction. + * + * @param string $field The field name + * @param string|null $defaultDirection The default direction or null + * @return $this + */ + public function field(string $field, ?string $defaultDirection = null) + { + $this->fields[] = new SortField($field, $defaultDirection, false); + + return $this; + } + + /** + * Build and return the array of SortField objects. + * + * @return array<\Cake\Datasource\Paging\SortField> + */ + public function build(): array + { + return $this->fields; + } + + /** + * Build a complete sortMap configuration. + * + * @param array> $mappings The sort mappings + * @return array> + */ + public static function buildMap(array $mappings): array + { + return $mappings; + } +} diff --git a/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php b/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php new file mode 100644 index 00000000000..183bb575f54 --- /dev/null +++ b/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php @@ -0,0 +1,484 @@ + + */ + protected array $fixtures = [ + 'core.Articles', + 'core.Authors', + ]; + + /** + * @var \Cake\ORM\Table + */ + protected Table $table; + + /** + * @var \Cake\Datasource\Paging\NumericPaginator + */ + protected NumericPaginator $paginator; + + /** + * setUp method + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); + $this->table = $this->getTableLocator()->get('Articles'); + $this->paginator = new NumericPaginator(); + } + + /** + * Test paginator with SortField objects for default ascending sort + * + * @return void + */ + public function testPaginateWithSortFieldAscending(): void + { + $params = [ + 'sort' => 'newest', + ]; + + $settings = [ + 'sortMap' => [ + 'newest' => [ + SortField::asc('title'), + SortField::desc('published'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // When no direction specified, should use SortField defaults + $expected = [ + 'Articles.title' => 'asc', + 'Articles.published' => 'desc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with SortField objects when direction is explicitly specified + * + * @return void + */ + public function testPaginateWithSortFieldExplicitDirection(): void + { + $params = [ + 'sort' => 'newest', + 'direction' => 'desc', + ]; + + $settings = [ + 'sortMap' => [ + 'newest' => [ + SortField::asc('title'), + SortField::desc('published'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // When direction is explicitly specified, toggleable fields should use it + $expected = [ + 'Articles.title' => 'desc', + 'Articles.published' => 'desc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with locked SortField objects + * + * @return void + */ + public function testPaginateWithLockedSortField(): void + { + $params = [ + 'sort' => 'popular', + 'direction' => 'asc', // Try to override the locked direction + ]; + + $settings = [ + 'sortMap' => [ + 'popular' => [ + SortField::locked('published', SortField::DESC), + SortField::asc('title'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Locked field should always use its locked direction + $expected = [ + 'Articles.published' => 'desc', // Locked, ignores requested 'asc' + 'Articles.title' => 'asc', // Toggleable, uses requested 'asc' + ]; + + $this->assertEquals('popular', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); // First field's direction is what gets reported + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with mixed SortField objects and strings for backward compatibility + * + * @return void + */ + public function testPaginateWithMixedSortFieldAndStrings(): void + { + $params = [ + 'sort' => 'mixed', + 'direction' => 'desc', + ]; + + $settings = [ + 'sortMap' => [ + 'mixed' => [ + SortField::desc('published'), + 'author_id', // String field for BC + SortField::locked('title', SortField::ASC), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', // SortField with default desc + 'Articles.author_id' => 'desc', // String field uses requested direction + 'Articles.title' => 'asc', // Locked field ignores requested direction + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with combined sort format and SortField + * + * @return void + */ + public function testPaginateWithCombinedSortFormat(): void + { + // Test with combined format: newest-desc + $params = [ + 'sort' => 'newest-desc', + ]; + + $settings = [ + 'sortMap' => [ + 'newest' => [ + SortField::asc('title'), + SortField::desc('published'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Direction is explicitly specified as desc + $expected = [ + 'Articles.title' => 'desc', + 'Articles.published' => 'desc', + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with SortField when no direction is provided in combined format + * + * @return void + */ + public function testPaginateWithCombinedSortFormatNoDirection(): void + { + // Test with combined format but no direction: just "newest" + $params = [ + 'sort' => 'newest', + ]; + + $settings = [ + 'sortMap' => [ + 'newest' => [ + SortField::desc('published'), + SortField::asc('title'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Should use default directions from SortField + $expected = [ + 'Articles.published' => 'desc', + 'Articles.title' => 'asc', + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test complex real-world scenario with multiple sort maps + * + * @return void + */ + public function testComplexRealWorldScenario(): void + { + $settings = [ + 'sortMap' => [ + 'relevance' => [ + SortField::locked('published', SortField::DESC), + SortField::desc('author_id'), + ], + 'newest' => [ + SortField::desc('published'), + SortField::asc('title'), + ], + 'alphabetical' => [ + SortField::asc('title'), + ], + 'author' => [ + 'author_id', + SortField::asc('title'), + ], + ], + ]; + + // Test relevance sort (with locked field) + $params = ['sort' => 'relevance', 'direction' => 'asc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', // Locked, ignores 'asc' + 'Articles.author_id' => 'asc', // Toggleable, uses 'asc' + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test newest sort without explicit direction + $params = ['sort' => 'newest']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', + 'Articles.title' => 'asc', + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test that invalid sort keys are handled correctly with SortField + * + * @return void + */ + public function testInvalidSortKeyWithSortField(): void + { + $params = [ + 'sort' => 'invalid_key', + 'direction' => 'asc', + ]; + + $settings = [ + 'sortMap' => [ + 'newest' => [ + SortField::desc('published'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + + // Invalid sort key should result in no sorting + $pagingParams = $result->pagingParams(); + $this->assertNull($pagingParams['sort']); + $this->assertNull($pagingParams['direction']); + } + + /** + * Test paginator with SortFieldFactory preset methods + * + * @return void + */ + public function testPaginateWithFactoryPresets(): void + { + $params = [ + 'sort' => 'newest', + ]; + + $settings = [ + 'sortMap' => [ + 'newest' => SortFieldFactory::create() + ->desc('published') + ->asc('title') + ->build(), + 'oldest' => SortFieldFactory::create() + ->asc('published') + ->build(), + 'alphabetical' => SortFieldFactory::create() + ->asc('title') + ->build(), + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', + 'Articles.title' => 'asc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with SortFieldFactory fluent interface + * + * @return void + */ + public function testPaginateWithFactoryFluentInterface(): void + { + $params = [ + 'sort' => 'custom', + 'direction' => 'asc', + ]; + + $settings = [ + 'sortMap' => [ + 'custom' => SortFieldFactory::create() + ->desc('published') + ->locked('author_id', SortField::ASC) + ->asc('title') + ->build(), + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'asc', // Toggleable, uses requested 'asc' + 'Articles.author_id' => 'asc', // Locked, ignores requested direction + 'Articles.title' => 'asc', // Toggleable, uses requested 'asc' + ]; + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with complete sortMap built using factory + * + * @return void + */ + public function testPaginateWithFactoryBuildMap(): void + { + $sortMap = SortFieldFactory::buildMap([ + 'newest' => SortFieldFactory::create()->desc('published')->build(), + 'popular' => SortFieldFactory::create()->locked('published', SortField::DESC)->build(), + 'alphabetical' => SortFieldFactory::create()->asc('title')->build(), + ]); + + $params = [ + 'sort' => 'popular', + 'direction' => 'asc', // Try to override locked direction + ]; + + $settings = [ + 'sortMap' => $sortMap, + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Popular preset is locked to desc + $expected = [ + 'Articles.published' => 'desc', + ]; + + $this->assertEquals('popular', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); // Locked field's direction is reported + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test combined sort format with factory + * + * @return void + */ + public function testCombinedSortFormatWithFactory(): void + { + $settings = [ + 'sortMap' => SortFieldFactory::buildMap([ + 'custom' => SortFieldFactory::create() + ->desc('published') + ->asc('title') + ->build(), + ]), + ]; + + // Test with combined format: custom-asc + $params = ['sort' => 'custom-asc']; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Direction is explicitly specified as asc + $expected = [ + 'Articles.published' => 'asc', + 'Articles.title' => 'asc', + ]; + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } +} diff --git a/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php index a7a42d7e5b9..17a9c587301 100644 --- a/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php +++ b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php @@ -193,7 +193,7 @@ public function testSortMapSimpleMapping(): void ], ]; - // Test sorting by mapped key 'name' + // Test sorting by mapped key 'name' $params = ['sort' => 'name', 'direction' => 'asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -202,7 +202,7 @@ public function testSortMapSimpleMapping(): void $this->assertEquals('asc', $pagingParams['direction']); $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); - // Test sorting by mapped key 'content' with desc direction + // Test sorting by mapped key 'content' with desc direction $params = ['sort' => 'content', 'direction' => 'desc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -226,27 +226,27 @@ public function testSortMapShorthandSyntax(): void ], ]; - // Test sorting by shorthand mapped key 'title' + // Test sorting by shorthand mapped key 'title' $params = ['sort' => 'title', 'direction' => 'asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); $this->assertEquals('title', $pagingParams['sort']); $this->assertEquals('asc', $pagingParams['direction']); - // Shorthand fields still get prefixed with table name for actual query + // Shorthand fields still get prefixed with table name for actual query $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); - // Test sorting by shorthand mapped key 'body' + // Test sorting by shorthand mapped key 'body' $params = ['sort' => 'body', 'direction' => 'desc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); $this->assertEquals('body', $pagingParams['sort']); $this->assertEquals('desc', $pagingParams['direction']); - // Shorthand fields still get prefixed with table name for actual query + // Shorthand fields still get prefixed with table name for actual query $this->assertEquals(['PaginatorPosts.body' => 'desc'], $pagingParams['completeSort']); - // Test that regular mapping still works alongside shorthand + // Test that regular mapping still works alongside shorthand $params = ['sort' => 'name', 'direction' => 'asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -269,7 +269,7 @@ public function testSortMapMultiColumnSorting(): void ], ]; - // Test multi-column sorting with 'titleauthor' + // Test multi-column sorting with 'titleauthor' $params = ['sort' => 'titleauthor', 'direction' => 'desc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -281,7 +281,7 @@ public function testSortMapMultiColumnSorting(): void 'PaginatorPosts.author_id' => 'desc', ], $pagingParams['completeSort']); - // Test multi-column sorting with 'relevance' + // Test multi-column sorting with 'relevance' $params = ['sort' => 'relevance', 'direction' => 'asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -304,16 +304,16 @@ public function testSortMapFixedDirectionSorting(): void 'sortMap' => [ 'fresh' => [ 'PaginatorPosts.title', - 'PaginatorPosts.body' => 'desc!', // Locked to desc with ! + 'PaginatorPosts.body' => 'desc!', // Locked to desc with ! ], 'popularity' => [ - 'PaginatorPosts.id' => 'desc!', // Locked to desc with ! + 'PaginatorPosts.id' => 'desc!', // Locked to desc with ! 'PaginatorPosts.author_id' => 'asc!', // Locked to asc with ! ], ], ]; - // Test 'fresh' with mixed directions (querystring direction for title, locked desc for body) + // Test 'fresh' with mixed directions (querystring direction for title, locked desc for body) $params = ['sort' => 'fresh', 'direction' => 'asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -325,7 +325,7 @@ public function testSortMapFixedDirectionSorting(): void 'PaginatorPosts.body' => 'desc', // Locked direction ], $pagingParams['completeSort']); - // Test 'popularity' with all locked directions + // Test 'popularity' with all locked directions $params = ['sort' => 'popularity', 'direction' => 'asc']; // Direction should be ignored $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -346,64 +346,64 @@ public function testSortMapToggleableAndLockedDirections(): void $settings = [ 'sortMap' => [ 'custom' => [ - 'PaginatorPosts.title' => 'asc', // Default asc, can toggle - 'PaginatorPosts.body' => 'desc', // Default desc, can toggle + 'PaginatorPosts.title' => 'asc', // Default asc, can toggle + 'PaginatorPosts.body' => 'desc', // Default desc, can toggle ], 'locked' => [ - 'PaginatorPosts.id' => 'desc!', // Locked to desc + 'PaginatorPosts.id' => 'desc!', // Locked to desc 'PaginatorPosts.author_id' => 'asc', // Default asc, can toggle ], ], ]; - // Test 'custom' with default directions (no direction in query) + // Test 'custom' with default directions (no direction in query) $params = ['sort' => 'custom']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); $this->assertEquals('custom', $pagingParams['sort']); $this->assertEquals([ - 'PaginatorPosts.title' => 'asc', // Uses default - 'PaginatorPosts.body' => 'desc', // Uses default + 'PaginatorPosts.title' => 'asc', // Uses default + 'PaginatorPosts.body' => 'desc', // Uses default ], $pagingParams['completeSort']); - // Test 'custom' with asc direction (should use defaults) + // Test 'custom' with asc direction (should use defaults) $params = ['sort' => 'custom', 'direction' => 'asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); $this->assertEquals([ - 'PaginatorPosts.title' => 'asc', // Default was asc, stays asc - 'PaginatorPosts.body' => 'asc', // Default was desc, flips to asc + 'PaginatorPosts.title' => 'asc', // Default was asc, stays asc + 'PaginatorPosts.body' => 'asc', // Default was desc, flips to asc ], $pagingParams['completeSort']); - // Test 'custom' with desc direction (should flip defaults) + // Test 'custom' with desc direction (should flip defaults) $params = ['sort' => 'custom', 'direction' => 'desc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); $this->assertEquals([ 'PaginatorPosts.title' => 'desc', // Default was asc, flips to desc - 'PaginatorPosts.body' => 'desc', // Default was desc, stays desc + 'PaginatorPosts.body' => 'desc', // Default was desc, stays desc ], $pagingParams['completeSort']); - // Test 'locked' with asc direction + // Test 'locked' with asc direction $params = ['sort' => 'locked', 'direction' => 'asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); $this->assertEquals([ - 'PaginatorPosts.id' => 'desc', // Locked, never changes + 'PaginatorPosts.id' => 'desc', // Locked, never changes 'PaginatorPosts.author_id' => 'asc', // Default asc, stays asc ], $pagingParams['completeSort']); - // Test 'locked' with desc direction + // Test 'locked' with desc direction $params = ['sort' => 'locked', 'direction' => 'desc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); $this->assertEquals([ - 'PaginatorPosts.id' => 'desc', // Locked, never changes + 'PaginatorPosts.id' => 'desc', // Locked, never changes 'PaginatorPosts.author_id' => 'desc', // Default asc, flips to desc ], $pagingParams['completeSort']); } @@ -420,12 +420,12 @@ public function testSortMapRejectsUnmappedKeys(): void ], ]; - // Try to sort by unmapped field + // Try to sort by unmapped field $params = ['sort' => 'body', 'direction' => 'asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); - // Sort should be cleared as it's not in sortMap + // Sort should be cleared as it's not in sortMap $this->assertNull($pagingParams['sort']); $this->assertNull($pagingParams['direction']); $this->assertEquals([], $pagingParams['completeSort']); @@ -438,7 +438,7 @@ public function testBackwardCompatibilityWithoutSortMap(): void { $table = $this->getTableLocator()->get('PaginatorPosts'); - // Test without sortMap - should work as before + // Test without sortMap - should work as before $params = ['sort' => 'title', 'direction' => 'desc']; $result = $this->Paginator->paginate($table, $params); $pagingParams = $result->pagingParams(); @@ -455,7 +455,7 @@ public function testCombinedSortDirectionFormat(): void { $table = $this->getTableLocator()->get('PaginatorPosts'); - // Test ascending with combined format + // Test ascending with combined format $params = ['sort' => 'title-asc']; $result = $this->Paginator->paginate($table, $params); $pagingParams = $result->pagingParams(); @@ -464,7 +464,7 @@ public function testCombinedSortDirectionFormat(): void $this->assertEquals('asc', $pagingParams['direction']); $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); - // Test descending with combined format + // Test descending with combined format $params = ['sort' => 'body-desc']; $result = $this->Paginator->paginate($table, $params); $pagingParams = $result->pagingParams(); @@ -473,7 +473,7 @@ public function testCombinedSortDirectionFormat(): void $this->assertEquals('desc', $pagingParams['direction']); $this->assertEquals(['PaginatorPosts.body' => 'desc'], $pagingParams['completeSort']); - // Test that traditional format still works + // Test that traditional format still works $params = ['sort' => 'title', 'direction' => 'desc']; $result = $this->Paginator->paginate($table, $params); $pagingParams = $result->pagingParams(); @@ -482,7 +482,7 @@ public function testCombinedSortDirectionFormat(): void $this->assertEquals('desc', $pagingParams['direction']); $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); - // Test combined format with hyphenated field names + // Test combined format with hyphenated field names $params = ['sort' => 'author_id-desc']; $result = $this->Paginator->paginate($table, $params); $pagingParams = $result->pagingParams(); @@ -504,13 +504,13 @@ public function testCombinedSortFormatWithSortMap(): void 'content' => 'PaginatorPosts.body', 'newest' => ['PaginatorPosts.id' => 'desc!', 'PaginatorPosts.title'], 'custom' => [ - 'PaginatorPosts.author_id' => 'asc', // Toggleable default - 'PaginatorPosts.body' => 'desc', // Toggleable default + 'PaginatorPosts.author_id' => 'asc', // Toggleable default + 'PaginatorPosts.body' => 'desc', // Toggleable default ], ], ]; - // Test simple mapping with combined format + // Test simple mapping with combined format $params = ['sort' => 'name-desc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -519,7 +519,7 @@ public function testCombinedSortFormatWithSortMap(): void $this->assertEquals('desc', $pagingParams['direction']); $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); - // Test that unmapped fields with combined format are still rejected + // Test that unmapped fields with combined format are still rejected $params = ['sort' => 'unmapped-asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -528,7 +528,7 @@ public function testCombinedSortFormatWithSortMap(): void $this->assertNull($pagingParams['direction']); $this->assertEquals([], $pagingParams['completeSort']); - // Test multi-field mapping with combined format (locked field) + // Test multi-field mapping with combined format (locked field) $params = ['sort' => 'newest-asc']; // Direction should apply to non-locked fields $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -539,7 +539,7 @@ public function testCombinedSortFormatWithSortMap(): void 'PaginatorPosts.title' => 'asc', // Uses combined format direction ], $pagingParams['completeSort']); - // Test toggleable defaults with combined format - asc + // Test toggleable defaults with combined format - asc $params = ['sort' => 'custom-asc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -547,10 +547,10 @@ public function testCombinedSortFormatWithSortMap(): void $this->assertEquals('custom', $pagingParams['sort']); $this->assertEquals([ 'PaginatorPosts.author_id' => 'asc', // Default asc, stays asc - 'PaginatorPosts.body' => 'asc', // Default desc, flips to asc + 'PaginatorPosts.body' => 'asc', // Default desc, flips to asc ], $pagingParams['completeSort']); - // Test toggleable defaults with combined format - desc + // Test toggleable defaults with combined format - desc $params = ['sort' => 'custom-desc']; $result = $this->Paginator->paginate($table, $params, $settings); $pagingParams = $result->pagingParams(); @@ -558,7 +558,7 @@ public function testCombinedSortFormatWithSortMap(): void $this->assertEquals('custom', $pagingParams['sort']); $this->assertEquals([ 'PaginatorPosts.author_id' => 'desc', // Default asc, flips to desc - 'PaginatorPosts.body' => 'desc', // Default desc, stays desc + 'PaginatorPosts.body' => 'desc', // Default desc, stays desc ], $pagingParams['completeSort']); } @@ -568,7 +568,7 @@ public function testCombinedSortFormatWithSortMap(): void public function testSortMapWithAssociations(): void { $table = $this->getTableLocator()->get('Articles'); - // Association is already set up in the Articles table + // Association is already set up in the Articles table $settings = [ 'sortMap' => [ @@ -577,7 +577,7 @@ public function testSortMapWithAssociations(): void ], ]; - // Test association field mapping + // Test association field mapping $params = ['sort' => 'author', 'direction' => 'asc']; $query = $table->find()->contain(['Authors']); $result = $this->Paginator->paginate($query, $params, $settings); diff --git a/tests/TestCase/Datasource/Paging/SortFieldFactoryTest.php b/tests/TestCase/Datasource/Paging/SortFieldFactoryTest.php new file mode 100644 index 00000000000..f08f49c934c --- /dev/null +++ b/tests/TestCase/Datasource/Paging/SortFieldFactoryTest.php @@ -0,0 +1,197 @@ +assertInstanceOf(SortFieldFactory::class, $factory); + } + + /** + * Test fluent interface for building sort fields + * + * @return void + */ + public function testFluentInterface(): void + { + $fields = SortFieldFactory::create() + ->asc('title') + ->desc('created') + ->locked('score', 'desc') + ->field('author', 'asc') + ->build(); + + $this->assertCount(4, $fields); + + // Test first field (asc) + $this->assertInstanceOf(SortField::class, $fields[0]); + $this->assertSame('title', $fields[0]->getField()); + $this->assertFalse($fields[0]->isLocked()); + $this->assertSame('asc', $fields[0]->getDirection('desc', false)); + + // Test second field (desc) + $this->assertInstanceOf(SortField::class, $fields[1]); + $this->assertSame('created', $fields[1]->getField()); + $this->assertFalse($fields[1]->isLocked()); + $this->assertSame('desc', $fields[1]->getDirection('asc', false)); + + // Test third field (locked) + $this->assertInstanceOf(SortField::class, $fields[2]); + $this->assertSame('score', $fields[2]->getField()); + $this->assertTrue($fields[2]->isLocked()); + $this->assertSame('desc', $fields[2]->getDirection('asc', true)); + + // Test fourth field (field with default) + $this->assertInstanceOf(SortField::class, $fields[3]); + $this->assertSame('author', $fields[3]->getField()); + $this->assertFalse($fields[3]->isLocked()); + $this->assertSame('asc', $fields[3]->getDirection('desc', false)); + } + + /** + * Test add() method with custom SortField + * + * @return void + */ + public function testAddCustomSortField(): void + { + $customField = new SortField('custom', 'desc', true); + + $fields = SortFieldFactory::create() + ->add($customField) + ->asc('title') + ->build(); + + $this->assertCount(2, $fields); + $this->assertSame($customField, $fields[0]); + $this->assertSame('custom', $fields[0]->getField()); + $this->assertTrue($fields[0]->isLocked()); + } + + /** + * Test buildMap() helper + * + * @return void + */ + public function testBuildMap(): void + { + $map = SortFieldFactory::buildMap([ + 'newest' => SortFieldFactory::create() + ->desc('created') + ->asc('title') + ->build(), + 'oldest' => SortFieldFactory::create() + ->asc('created') + ->asc('title') + ->build(), + 'custom' => SortFieldFactory::create() + ->desc('score') + ->asc('title') + ->build(), + ]); + + $this->assertIsArray($map); + $this->assertArrayHasKey('newest', $map); + $this->assertArrayHasKey('oldest', $map); + $this->assertArrayHasKey('custom', $map); + + // Check newest configuration + $this->assertCount(2, $map['newest']); + $this->assertInstanceOf(SortField::class, $map['newest'][0]); + $this->assertSame('created', $map['newest'][0]->getField()); + $this->assertSame(SortField::DESC, $map['newest'][0]->getDirection(SortField::ASC, false)); + + // Check custom configuration + $this->assertCount(2, $map['custom']); + $this->assertInstanceOf(SortField::class, $map['custom'][0]); + $this->assertSame('score', $map['custom'][0]->getField()); + } + + /** + * Test complex real-world usage example + * + * @return void + */ + public function testComplexRealWorldExample(): void + { + // Build a complex sortMap for an e-commerce product listing + $sortMap = [ + 'relevance' => SortFieldFactory::create() + ->locked('search_score', SortField::DESC) + ->desc('popularity') + ->asc('title') + ->build(), + 'price_low' => SortFieldFactory::create() + ->locked('price', SortField::ASC) + ->asc('title') + ->build(), + 'price_high' => SortFieldFactory::create() + ->locked('price', SortField::DESC) + ->asc('title') + ->build(), + 'newest' => SortFieldFactory::create() + ->desc('created_at') + ->asc('title') + ->build(), + 'bestselling' => SortFieldFactory::create() + ->locked('sales_count', SortField::DESC) + ->desc('rating') + ->build(), + 'rating' => SortFieldFactory::create() + ->desc('rating') + ->desc('review_count') + ->asc('title') + ->build(), + ]; + + // Test relevance sort + $this->assertCount(3, $sortMap['relevance']); + $this->assertTrue($sortMap['relevance'][0]->isLocked()); + $this->assertSame('search_score', $sortMap['relevance'][0]->getField()); + + // Test price sorts + $this->assertCount(2, $sortMap['price_low']); + $this->assertTrue($sortMap['price_low'][0]->isLocked()); + $this->assertSame(SortField::ASC, $sortMap['price_low'][0]->getDirection(SortField::DESC, true)); + + $this->assertCount(2, $sortMap['price_high']); + $this->assertTrue($sortMap['price_high'][0]->isLocked()); + $this->assertSame(SortField::DESC, $sortMap['price_high'][0]->getDirection(SortField::ASC, true)); + + // Test rating sort + $this->assertCount(3, $sortMap['rating']); + $this->assertFalse($sortMap['rating'][0]->isLocked()); + $this->assertSame('rating', $sortMap['rating'][0]->getField()); + $this->assertSame('review_count', $sortMap['rating'][1]->getField()); + $this->assertSame('title', $sortMap['rating'][2]->getField()); + } +} diff --git a/tests/TestCase/Datasource/Paging/SortFieldTest.php b/tests/TestCase/Datasource/Paging/SortFieldTest.php new file mode 100644 index 00000000000..5885f011aec --- /dev/null +++ b/tests/TestCase/Datasource/Paging/SortFieldTest.php @@ -0,0 +1,190 @@ +assertSame('created', $field->getField()); + $this->assertFalse($field->isLocked()); + + $lockedField = new SortField('score', SortField::ASC, true); + $this->assertSame('score', $lockedField->getField()); + $this->assertTrue($lockedField->isLocked()); + } + + /** + * Test asc() static factory method + * + * @return void + */ + public function testAscFactory(): void + { + $field = SortField::asc('title'); + $this->assertSame('title', $field->getField()); + $this->assertFalse($field->isLocked()); + + // Should use default direction when no direction specified + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, false)); + + // Should allow override when direction is specified + $this->assertSame(SortField::DESC, $field->getDirection(SortField::DESC, true)); + } + + /** + * Test desc() static factory method + * + * @return void + */ + public function testDescFactory(): void + { + $field = SortField::desc('created'); + $this->assertSame('created', $field->getField()); + $this->assertFalse($field->isLocked()); + + // Should use default direction when no direction specified + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, false)); + + // Should allow override when direction is specified + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, true)); + } + + /** + * Test locked() static factory method + * + * @return void + */ + public function testLockedFactory(): void + { + $field = SortField::locked('score', SortField::DESC); + $this->assertSame('score', $field->getField()); + $this->assertTrue($field->isLocked()); + + // Should always return locked direction regardless of request + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, false)); + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, true)); + } + + /** + * Test getDirection() with no default direction + * + * @return void + */ + public function testGetDirectionNoDefault(): void + { + $field = new SortField('name', null, false); + + // Should use requested direction when no default is set + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, false)); + $this->assertSame(SortField::DESC, $field->getDirection(SortField::DESC, false)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, true)); + $this->assertSame(SortField::DESC, $field->getDirection(SortField::DESC, true)); + } + + /** + * Test getDirection() with default direction + * + * @return void + */ + public function testGetDirectionWithDefault(): void + { + $field = new SortField('created', SortField::DESC, false); + + // Should use default when direction not specified + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, false)); + + // Should use requested direction when explicitly specified + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, true)); + $this->assertSame(SortField::DESC, $field->getDirection(SortField::DESC, true)); + } + + /** + * Test locked field behavior + * + * @return void + */ + public function testLockedFieldBehavior(): void + { + $field = new SortField('priority', SortField::ASC, true); + + // Locked field should always return its default direction + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, false)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, true)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, false)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, true)); + } + + /** + * Test usage examples from the documentation + * + * @return void + */ + public function testUsageExamples(): void + { + // Example sortMap configuration + $sortMap = [ + 'newest' => [ + SortField::desc('created'), // Default desc, toggleable + SortField::asc('title'), // Default asc, toggleable + ], + 'popular' => [ + SortField::locked('score', SortField::DESC), // Always desc + 'author', // Still support strings for BC + ], + ]; + + // Test 'newest' configuration + $newestFields = $sortMap['newest']; + + $createdField = $newestFields[0]; + $this->assertInstanceOf(SortField::class, $createdField); + $this->assertSame('created', $createdField->getField()); + $this->assertSame(SortField::DESC, $createdField->getDirection(SortField::ASC, false)); + $this->assertSame(SortField::ASC, $createdField->getDirection(SortField::ASC, true)); + + $titleField = $newestFields[1]; + $this->assertInstanceOf(SortField::class, $titleField); + $this->assertSame('title', $titleField->getField()); + $this->assertSame(SortField::ASC, $titleField->getDirection(SortField::DESC, false)); + $this->assertSame(SortField::DESC, $titleField->getDirection(SortField::DESC, true)); + + // Test 'popular' configuration + $popularFields = $sortMap['popular']; + + $scoreField = $popularFields[0]; + $this->assertInstanceOf(SortField::class, $scoreField); + $this->assertSame('score', $scoreField->getField()); + $this->assertSame(SortField::DESC, $scoreField->getDirection(SortField::ASC, true)); + $this->assertTrue($scoreField->isLocked()); + + // Test backward compatibility with string + $this->assertSame('author', $popularFields[1]); + } +} From 1989178e7e2f63c2e7967eee72a5d1c4d282eacf Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 14 Sep 2025 13:11:26 +0200 Subject: [PATCH 5/7] Add factory and object approach. --- src/Datasource/Paging/NumericPaginator.php | 26 +++++++++------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Datasource/Paging/NumericPaginator.php b/src/Datasource/Paging/NumericPaginator.php index cf75e4be12f..3f65c23e938 100644 --- a/src/Datasource/Paging/NumericPaginator.php +++ b/src/Datasource/Paging/NumericPaginator.php @@ -801,22 +801,18 @@ protected function resolveSortMapping( // Indexed array: field uses querystring direction // e.g., ['modified', 'name'] $order[$value] = $direction; - } else { + } elseif (str_ends_with($value, '!')) { // Associative array: check for locked (!) or default direction - if (str_ends_with($value, '!')) { - // Locked direction (ends with !): always use specified direction - // e.g., ['created' => 'desc!'] always sorts desc - $order[$key] = rtrim($value, '!'); - } else { - // Default direction that can be toggled - if (!$directionSpecified) { - // No direction specified, use the default - $order[$key] = $value; - } else { - // Direction specified, use it for all toggleable fields - $order[$key] = $direction; - } - } + // Locked direction (ends with !): always use specified direction + // e.g., ['created' => 'desc!'] always sorts desc + $order[$key] = rtrim($value, '!'); + } elseif (!$directionSpecified) { + // Default direction that can be toggled + // No direction specified, use the default + $order[$key] = $value; + } else { + // Direction specified, use it for all toggleable fields + $order[$key] = $direction; } } From 3bfc2dd0037f07a6b0763f066e5543adf45fa55c Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 14 Sep 2025 13:42:47 +0200 Subject: [PATCH 6/7] Add factory and object approach. --- src/Datasource/Paging/SortFieldFactory.php | 13 +- src/Datasource/Paging/SortMapFactory.php | 174 +++++++++++++ .../Paging/NumericPaginatorSortFieldTest.php | 98 ++++++- .../Paging/SortFieldFactoryTest.php | 39 --- .../Datasource/Paging/SortMapFactoryTest.php | 244 ++++++++++++++++++ 5 files changed, 513 insertions(+), 55 deletions(-) create mode 100644 src/Datasource/Paging/SortMapFactory.php create mode 100644 tests/TestCase/Datasource/Paging/SortMapFactoryTest.php diff --git a/src/Datasource/Paging/SortFieldFactory.php b/src/Datasource/Paging/SortFieldFactory.php index b8a91126d2f..d61f8cbaf34 100644 --- a/src/Datasource/Paging/SortFieldFactory.php +++ b/src/Datasource/Paging/SortFieldFactory.php @@ -19,7 +19,7 @@ /** * Factory for creating SortField configurations. * - * Provides fluent interface for building complex sort configurations. + * Provides fluent interface for building sort field collections. */ class SortFieldFactory { @@ -114,15 +114,4 @@ public function build(): array { return $this->fields; } - - /** - * Build a complete sortMap configuration. - * - * @param array> $mappings The sort mappings - * @return array> - */ - public static function buildMap(array $mappings): array - { - return $mappings; - } } diff --git a/src/Datasource/Paging/SortMapFactory.php b/src/Datasource/Paging/SortMapFactory.php new file mode 100644 index 00000000000..0f4263f91fb --- /dev/null +++ b/src/Datasource/Paging/SortMapFactory.php @@ -0,0 +1,174 @@ +> The sort map being built + */ + protected array $map = []; + + /** + * @var array<\Cake\Datasource\Paging\SortField> The current fields being built + */ + protected array $fields = []; + + /** + * @var string|null The current sort key being configured + */ + protected ?string $currentKey = null; + + /** + * Create a new factory instance. + * + * @return self + */ + public static function create(): self + { + return new self(); + } + + /** + * Start defining a new sort key in the map. + * + * @param string $sortKey The sort key name + * @return $this + */ + public function sortKey(string $sortKey) + { + // Save current fields to map if we have a current key + if ($this->currentKey !== null) { + if (!empty($this->fields)) { + $this->map[$this->currentKey] = $this->fields; + } elseif (!isset($this->map[$this->currentKey])) { + // If no fields were added, use the key as the field name + $this->map[$this->currentKey] = [$this->currentKey]; + } + $this->fields = []; + } + + $this->currentKey = $sortKey; + + return $this; + } + + /** + * Add a field with ascending default direction. + * + * @param string $field The field name + * @return $this + */ + public function asc(string $field) + { + $this->fields[] = SortField::asc($field); + + return $this; + } + + /** + * Add a field with descending default direction. + * + * @param string $field The field name + * @return $this + */ + public function desc(string $field) + { + $this->fields[] = SortField::desc($field); + + return $this; + } + + /** + * Add a locked field with fixed direction. + * + * @param string $field The field name + * @param string $direction The fixed direction (SortField::ASC or SortField::DESC) + * @return $this + */ + public function locked(string $field, string $direction) + { + $this->fields[] = SortField::locked($field, $direction); + + return $this; + } + + /** + * Add a toggleable field with optional default direction. + * + * @param string $field The field name + * @param string|null $defaultDirection The default direction or null + * @return $this + */ + public function field(string $field, ?string $defaultDirection = null) + { + $this->fields[] = new SortField($field, $defaultDirection, false); + + return $this; + } + + /** + * Add a custom SortField instance. + * + * @param \Cake\Datasource\Paging\SortField $sortField The sort field to add + * @return $this + */ + public function add(SortField $sortField) + { + $this->fields[] = $sortField; + + return $this; + } + + /** + * Add a plain string field (for backward compatibility). + * + * @param string $field The field name + * @return $this + */ + public function string(string $field) + { + $this->fields[] = $field; + + return $this; + } + + /** + * Build and return the complete sortMap. + * + * @return array> + */ + public function build(): array + { + // Save any pending fields + if ($this->currentKey !== null) { + if (!empty($this->fields)) { + $this->map[$this->currentKey] = $this->fields; + } elseif (!isset($this->map[$this->currentKey])) { + // If no fields were added, use the key as the field name + $this->map[$this->currentKey] = [$this->currentKey]; + } + } + + return $this->map; + } +} diff --git a/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php b/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php index 183bb575f54..d0aaca409b5 100644 --- a/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php +++ b/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php @@ -19,6 +19,7 @@ use Cake\Datasource\Paging\NumericPaginator; use Cake\Datasource\Paging\SortField; use Cake\Datasource\Paging\SortFieldFactory; +use Cake\Datasource\Paging\SortMapFactory; use Cake\ORM\Table; use Cake\TestSuite\TestCase; @@ -421,11 +422,11 @@ public function testPaginateWithFactoryFluentInterface(): void */ public function testPaginateWithFactoryBuildMap(): void { - $sortMap = SortFieldFactory::buildMap([ + $sortMap = [ 'newest' => SortFieldFactory::create()->desc('published')->build(), 'popular' => SortFieldFactory::create()->locked('published', SortField::DESC)->build(), 'alphabetical' => SortFieldFactory::create()->asc('title')->build(), - ]); + ]; $params = [ 'sort' => 'popular', @@ -449,6 +450,53 @@ public function testPaginateWithFactoryBuildMap(): void $this->assertEquals($expected, $pagingParams['completeSort']); } + /** + * Test paginator with SortMapFactory + * + * @return void + */ + public function testSortMapFactory(): void + { + $settings = [ + 'sortMap' => SortMapFactory::create() + ->sortKey('newest') + ->desc('published') + ->asc('title') + ->sortKey('popular') + ->locked('published', SortField::DESC) + ->desc('author_id') + ->sortKey('alphabetical') + ->asc('title') + ->build(), + ]; + + // Test newest sort + $params = ['sort' => 'newest']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', + 'Articles.title' => 'asc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test popular sort with locked field + $params = ['sort' => 'popular', 'direction' => 'asc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', // Locked + 'Articles.author_id' => 'asc', // Toggleable + ]; + + $this->assertEquals('popular', $pagingParams['sort']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + /** * Test combined sort format with factory * @@ -457,12 +505,12 @@ public function testPaginateWithFactoryBuildMap(): void public function testCombinedSortFormatWithFactory(): void { $settings = [ - 'sortMap' => SortFieldFactory::buildMap([ + 'sortMap' => [ 'custom' => SortFieldFactory::create() ->desc('published') ->asc('title') ->build(), - ]), + ], ]; // Test with combined format: custom-asc @@ -481,4 +529,46 @@ public function testCombinedSortFormatWithFactory(): void $this->assertEquals('asc', $pagingParams['direction']); $this->assertEquals($expected, $pagingParams['completeSort']); } + + /** + * Test SortMapFactory shorthand where key is used as field + * + * @return void + */ + public function testSortMapFactoryShorthand(): void + { + $settings = [ + 'sortMap' => SortMapFactory::create() + ->sortKey('title') // Shorthand - uses 'title' as field + ->sortKey('published') // Shorthand - uses 'published' as field + ->sortKey('author_id') // Shorthand - uses 'author_id' as field + ->build(), + ]; + + // Test title sort + $params = ['sort' => 'title', 'direction' => 'desc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.title' => 'desc', + ]; + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test published sort + $params = ['sort' => 'published', 'direction' => 'asc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'asc', + ]; + + $this->assertEquals('published', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } } diff --git a/tests/TestCase/Datasource/Paging/SortFieldFactoryTest.php b/tests/TestCase/Datasource/Paging/SortFieldFactoryTest.php index f08f49c934c..fa768608e2c 100644 --- a/tests/TestCase/Datasource/Paging/SortFieldFactoryTest.php +++ b/tests/TestCase/Datasource/Paging/SortFieldFactoryTest.php @@ -97,45 +97,6 @@ public function testAddCustomSortField(): void $this->assertTrue($fields[0]->isLocked()); } - /** - * Test buildMap() helper - * - * @return void - */ - public function testBuildMap(): void - { - $map = SortFieldFactory::buildMap([ - 'newest' => SortFieldFactory::create() - ->desc('created') - ->asc('title') - ->build(), - 'oldest' => SortFieldFactory::create() - ->asc('created') - ->asc('title') - ->build(), - 'custom' => SortFieldFactory::create() - ->desc('score') - ->asc('title') - ->build(), - ]); - - $this->assertIsArray($map); - $this->assertArrayHasKey('newest', $map); - $this->assertArrayHasKey('oldest', $map); - $this->assertArrayHasKey('custom', $map); - - // Check newest configuration - $this->assertCount(2, $map['newest']); - $this->assertInstanceOf(SortField::class, $map['newest'][0]); - $this->assertSame('created', $map['newest'][0]->getField()); - $this->assertSame(SortField::DESC, $map['newest'][0]->getDirection(SortField::ASC, false)); - - // Check custom configuration - $this->assertCount(2, $map['custom']); - $this->assertInstanceOf(SortField::class, $map['custom'][0]); - $this->assertSame('score', $map['custom'][0]->getField()); - } - /** * Test complex real-world usage example * diff --git a/tests/TestCase/Datasource/Paging/SortMapFactoryTest.php b/tests/TestCase/Datasource/Paging/SortMapFactoryTest.php new file mode 100644 index 00000000000..ce092be28a6 --- /dev/null +++ b/tests/TestCase/Datasource/Paging/SortMapFactoryTest.php @@ -0,0 +1,244 @@ +assertInstanceOf(SortMapFactory::class, $factory); + } + + /** + * Test fluent interface for building complete sortMaps + * + * @return void + */ + public function testFluentInterface(): void + { + $sortMap = SortMapFactory::create() + ->sortKey('newest') + ->desc('created') + ->asc('title') + ->sortKey('oldest') + ->asc('created') + ->asc('title') + ->sortKey('popular') + ->locked('score', SortField::DESC) + ->desc('views') + ->sortKey('alphabetical') + ->asc('name') + ->build(); + + $this->assertArrayHasKey('newest', $sortMap); + $this->assertArrayHasKey('oldest', $sortMap); + $this->assertArrayHasKey('popular', $sortMap); + $this->assertArrayHasKey('alphabetical', $sortMap); + + // Check newest configuration + $this->assertCount(2, $sortMap['newest']); + $this->assertSame('created', $sortMap['newest'][0]->getField()); + $this->assertSame(SortField::DESC, $sortMap['newest'][0]->getDirection(SortField::ASC, false)); + $this->assertSame('title', $sortMap['newest'][1]->getField()); + + // Check popular configuration + $this->assertCount(2, $sortMap['popular']); + $this->assertTrue($sortMap['popular'][0]->isLocked()); + $this->assertSame('score', $sortMap['popular'][0]->getField()); + $this->assertSame('views', $sortMap['popular'][1]->getField()); + + // Check alphabetical configuration + $this->assertCount(1, $sortMap['alphabetical']); + $this->assertSame('name', $sortMap['alphabetical'][0]->getField()); + } + + /** + * Test mixing SortField objects and strings + * + * @return void + */ + public function testMixedFieldTypes(): void + { + $customField = SortField::locked('custom', SortField::ASC); + + $sortMap = SortMapFactory::create() + ->sortKey('mixed') + ->add($customField) + ->string('plain_field') + ->desc('regular') + ->build(); + + $this->assertCount(3, $sortMap['mixed']); + $this->assertSame($customField, $sortMap['mixed'][0]); + $this->assertSame('plain_field', $sortMap['mixed'][1]); + $this->assertInstanceOf(SortField::class, $sortMap['mixed'][2]); + } + + /** + * Test complex e-commerce example + * + * @return void + */ + public function testComplexEcommerceExample(): void + { + $sortMap = SortMapFactory::create() + ->sortKey('relevance') + ->locked('search_score', SortField::DESC) + ->desc('popularity') + ->asc('title') + ->sortKey('price_low') + ->locked('price', SortField::ASC) + ->asc('title') + ->sortKey('price_high') + ->locked('price', SortField::DESC) + ->asc('title') + ->sortKey('newest') + ->desc('created_at') + ->asc('title') + ->sortKey('bestselling') + ->locked('sales_count', SortField::DESC) + ->desc('rating') + ->sortKey('rating') + ->desc('rating') + ->desc('review_count') + ->asc('title') + ->build(); + + // Test relevance sort + $this->assertCount(3, $sortMap['relevance']); + $this->assertTrue($sortMap['relevance'][0]->isLocked()); + $this->assertSame('search_score', $sortMap['relevance'][0]->getField()); + + // Test price sorts + $this->assertCount(2, $sortMap['price_low']); + $this->assertTrue($sortMap['price_low'][0]->isLocked()); + $this->assertSame(SortField::ASC, $sortMap['price_low'][0]->getDirection(SortField::DESC, true)); + + $this->assertCount(2, $sortMap['price_high']); + $this->assertTrue($sortMap['price_high'][0]->isLocked()); + $this->assertSame(SortField::DESC, $sortMap['price_high'][0]->getDirection(SortField::ASC, true)); + + // Test rating sort + $this->assertCount(3, $sortMap['rating']); + $this->assertFalse($sortMap['rating'][0]->isLocked()); + $this->assertSame('rating', $sortMap['rating'][0]->getField()); + } + + /** + * Test integration with SortFieldFactory + * + * @return void + */ + public function testIntegrationWithSortFieldFactory(): void + { + // You can still use SortFieldFactory for individual sort configurations + $newestFields = SortFieldFactory::create() + ->desc('created') + ->asc('title') + ->build(); + + $popularFields = SortFieldFactory::create() + ->locked('score', SortField::DESC) + ->desc('views') + ->build(); + + // And combine them in a map + $sortMap = [ + 'newest' => $newestFields, + 'popular' => $popularFields, + ]; + + $this->assertCount(2, $sortMap['newest']); + $this->assertCount(2, $sortMap['popular']); + } + + /** + * Test shorthand where sort key is used as field name + * + * @return void + */ + public function testShorthandSortKeyAsField(): void + { + // When no fields are added, the sort key becomes the field + $sortMap = SortMapFactory::create() + ->sortKey('created') + ->sortKey('title') + ->sortKey('author') + ->build(); + + $this->assertArrayHasKey('created', $sortMap); + $this->assertArrayHasKey('title', $sortMap); + $this->assertArrayHasKey('author', $sortMap); + + // Each should have the key as the field + $this->assertCount(1, $sortMap['created']); + $this->assertSame('created', $sortMap['created'][0]); + + $this->assertCount(1, $sortMap['title']); + $this->assertSame('title', $sortMap['title'][0]); + + $this->assertCount(1, $sortMap['author']); + $this->assertSame('author', $sortMap['author'][0]); + } + + /** + * Test mixed shorthand and explicit fields + * + * @return void + */ + public function testMixedShorthandAndExplicitFields(): void + { + $sortMap = SortMapFactory::create() + ->sortKey('created') // Shorthand - uses 'created' as field + ->sortKey('newest') + ->desc('created_at') + ->asc('title') + ->sortKey('title') // Back to shorthand + ->sortKey('popular') + ->locked('score', SortField::DESC) + ->build(); + + // Check shorthand keys + $this->assertCount(1, $sortMap['created']); + $this->assertSame('created', $sortMap['created'][0]); + + $this->assertCount(1, $sortMap['title']); + $this->assertSame('title', $sortMap['title'][0]); + + // Check explicit field configurations + $this->assertCount(2, $sortMap['newest']); + $this->assertSame('created_at', $sortMap['newest'][0]->getField()); + $this->assertSame('title', $sortMap['newest'][1]->getField()); + + $this->assertCount(1, $sortMap['popular']); + $this->assertTrue($sortMap['popular'][0]->isLocked()); + } +} From 3e5d264c199118d3d56cf868b9d45b1247c918d0 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 14 Sep 2025 13:53:10 +0200 Subject: [PATCH 7/7] Add factory and object approach. --- src/Datasource/Paging/SortMapFactory.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Datasource/Paging/SortMapFactory.php b/src/Datasource/Paging/SortMapFactory.php index 0f4263f91fb..0430b8597d7 100644 --- a/src/Datasource/Paging/SortMapFactory.php +++ b/src/Datasource/Paging/SortMapFactory.php @@ -29,7 +29,7 @@ class SortMapFactory protected array $map = []; /** - * @var array<\Cake\Datasource\Paging\SortField> The current fields being built + * @var array<\Cake\Datasource\Paging\SortField|string> The current fields being built */ protected array $fields = []; @@ -58,7 +58,7 @@ public function sortKey(string $sortKey) { // Save current fields to map if we have a current key if ($this->currentKey !== null) { - if (!empty($this->fields)) { + if ($this->fields !== []) { $this->map[$this->currentKey] = $this->fields; } elseif (!isset($this->map[$this->currentKey])) { // If no fields were added, use the key as the field name @@ -161,7 +161,7 @@ public function build(): array { // Save any pending fields if ($this->currentKey !== null) { - if (!empty($this->fields)) { + if ($this->fields !== []) { $this->map[$this->currentKey] = $this->fields; } elseif (!isset($this->map[$this->currentKey])) { // If no fields were added, use the key as the field name