Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

dereuromark
Copy link
Member

Resolves #18897

Feel free to further adjust.

@dereuromark dereuromark added this to the 5.3.0 milestone Sep 9, 2025
@josbeir
Copy link
Contributor

josbeir commented Sep 9, 2025

Looks good, should we handle what i mentioned here: #18897 (comment)

(sharing the same field name on multiple sortmaps)

@dereuromark
Copy link
Member Author

I don't think we need validation to prevent "overlapping" fields since each mapped key represents a complete, independent sorting strategy. This is actually a feature - it lets you create multiple sort options that include
the same fields in different combinations.

The concern about ambiguity is resolved by the design:

  • No ambiguity exists because direct field sorting is disabled when sortMap is defined
  • Each sort key (item1, item2) is self-contained and independent
  • The question "In what direction should title be sorted?" doesn't arise because title itself is not a valid sort key

@ADmad ADmad requested a review from Copilot September 9, 2025 13:13
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces a new sortMap feature to the NumericPaginator class that allows creating friendly sort keys that map to one or more actual database fields. The implementation provides advanced sorting capabilities including simple 1:1 mappings, multi-column sorting, fixed direction sorting, and shorthand syntax support.

Key changes include:

  • Added sortMap configuration option to allow mapping user-friendly sort keys to database fields
  • Implemented support for multiple sorting patterns including simple mapping, multi-column sorting, and fixed directions
  • Enhanced test coverage with comprehensive test cases for all sortMap functionality

Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/Datasource/Paging/NumericPaginator.php Added sortMap configuration and resolveSortMapping method to handle mapped sorting logic
tests/TestCase/Datasource/Paging/NumericPaginatorTest.php Added comprehensive test cases covering all sortMap functionality scenarios
tests/TestCase/Datasource/Paging/PaginatorTestTrait.php Updated test expectations to include sortMap null default in merged options

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment on lines 568 to 583
// 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 {
Copy link
Preview

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The logic for handling sortMap vs traditional sorting creates nested conditional blocks that reduce readability. Consider extracting the sortMap handling into a separate method to improve code organization and maintainability.

Copilot uses AI. Check for mistakes.

@josbeir
Copy link
Contributor

josbeir commented Sep 9, 2025

I don't think we need validation to prevent "overlapping" fields since each mapped key represents a complete, independent sorting strategy. This is actually a feature - it lets you create multiple sort options that include the same fields in different combinations.

The concern about ambiguity is resolved by the design:

* No ambiguity exists because direct field sorting is disabled when sortMap is defined

* Each sort key (item1, item2) is self-contained and independent

* The question "In what direction should title be sorted?" doesn't arise because title itself is not a valid sort key

Hmm, i'm probably not understanding it completely but

'sortMap' => [
  'item1' => ['title', 'created']
  'item2' => ['title', 'modified']
]

// sorting on both definitions: 
&item1=asc&item2=desc

Translates into the following statement right ?

SORT BY
   title ASC
   created ASC,
   title DESC,
   modified DESC 

@ADmad
Copy link
Member

ADmad commented Sep 9, 2025

&item1=asc&item2=desc

The paginatorhelper doesn't generate query strings like this. The direction can be specific only for 1 field, sort=item1&dir=asc

@dereuromark
Copy link
Member Author

dereuromark commented Sep 9, 2025

Imo we dont even need even sort asc/desc if we bind that into the key.

sort=title-asc
sort=title-desc

A flat list with all possible options

@ADmad
Copy link
Member

ADmad commented Sep 9, 2025

Imo we dont even need even sort asc/desc if we bind that into the key.

Yes that could be an additional enhancement, though what I would really like is the ability to do multi field/colum sort. The query string for it could be like sort[f1]=asc&sort[f2]=desc

@josbeir
Copy link
Contributor

josbeir commented Sep 9, 2025

@ADmad I think that kind of sort style is not very readable for the end user. This is something Drupal (facets/views) does and it is constantly a fight to get right for proper SEO

Maybe something like /products?sort=price:asc,rating:desc would be more friendly ?
or /products?sort=+price,-rating

With that said, we should of course be weary not to go back to Cake 1-2.x style query params. Good times... 🫠

Another thought that crosses my mind is then making sure that it is always returned in the same order to keep a canonical structure.

@ADmad
Copy link
Member

ADmad commented Sep 9, 2025

I think that kind of sort style is not very readable for the end user

The average non-techie user doesn't read/understand query string params :)

This is something Drupal (facets/views) does and it is constantly a fight to get right for proper SEO

I am not knowledgeable enough about how search engines deal with query string these days to be able to comment on this.

Maybe something like /products?sort=price:asc,rating:desc would be more friendly

This could work I guess.

BTW if for e.g. you have a search form with a multi-value field and using PRG you will get foo[] in the query string :)

@markstory
Copy link
Member

Translates into the following statement right ?

Yes. You ask a bad question, you get a bad answer. The SQL you posted is syntactically correct, and is what I would expect to happen if all of those ordering clauses were combined.

A flat list with all possible options

This could work but we'll need to make PaginatorHelper aware of how to change direction. How would application code express that field-asc and field-desc are related and opposite directions?

With that said, we should of course be weary not to go back to Cake 1-2.x style query params. Good times... 🫠

No thank you 😄

Comment on lines +52 to +56
* - `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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has some overlap with sortableFields should we keep both long term?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sortableFields does become redundant with the addition of sortMap, so it could be deprecated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think we can make this into something that is just as simple to use and handles more advanced use cases as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isnt it already now? :)

@dereuromark
Copy link
Member Author

I added a demo of combined sort & dir.
Wonder if that would work out better.

@dereuromark
Copy link
Member Author

dereuromark commented Sep 11, 2025

For me it looks quite complete.

What's Missing:

A way to specify default directions that can still be toggled. For example:

  • Default: title ASC, created DESC
  • When toggled: title DESC, created ASC

Potential Solution:

We could enhance the syntax to support default directions that flip together:

  'sortMap' => [
      // New syntax: use '@' prefix for toggleable fields with defaults
      'newest' => [
          '@title' => 'asc',     // Default asc, flips to desc
          '@created' => 'desc',  // Default desc, flips to asc
      ],
  ]

When user clicks the sort link:

  • First click: title ASC, created DESC (defaults)
  • Second click: title DESC, created ASC (inverted)

We could also invert this and make the @ signal that this is hardcoding the direction, as this might be more rare used.
Or, thinking about it (and in line of other languages, e.g. CSS),

  'newest' => [
      'title' => 'asc!',     // Only asc
      'created' => 'desc',  // Default desc, flips to asc
  ],

Use ! to signal that this is hardcoded in that direction.

@josbeir
Copy link
Contributor

josbeir commented Sep 11, 2025

For me it looks quite complete.

What's Missing:

A way to specify default directions that can still be toggled. For example:

* Default: title ASC, created DESC

* When toggled: title DESC, created ASC

Potential Solution:

We could enhance the syntax to support default directions that flip together:

  'sortMap' => [
      // New syntax: use '@' prefix for toggleable fields with defaults
      'newest' => [
          '@title' => 'asc',     // Default asc, flips to desc
          '@created' => 'desc',  // Default desc, flips to asc
      ],
  ]

When user clicks the sort link:

* First click: title ASC, created DESC (defaults)

* Second click: title DESC, created ASC (inverted)

We could also invert this and make the @ signal that this is hardcoding the direction, as this might be more rare used. Or, thinking about it (and in line of other languages, e.g. CSS),

  'newest' => [
      'title' => 'asc!',     // Only asc
      'created' => 'desc',  // Default desc, flips to asc
  ],

Use ! to signal that this is hardcoded in that direction.

I like the idea!

@rochamarcelo
Copy link
Contributor

Could we have classes to define how a field should be ordered instead of a new syntax?

class OrderField 
{
  
    public function defaultDir(): ?string
    {
    }

    public function isFixedDir(): bool
    {
    }

    public function ormColumn(): array
}

@dereuromark
Copy link
Member Author

As opt-in? I feel like as default this could become quite some overhead for apps to create.

@dereuromark
Copy link
Member Author

or do you mean sth like

 namespace Cake\Datasource\Paging;

  class SortField 
  {
      protected string $field;
      protected ?string $defaultDirection;
      protected bool $locked;

      public function __construct(string $field, ?string $defaultDirection = null, bool $locked = false)
      {
          $this->field = $field;
          $this->defaultDirection = $defaultDirection;
          $this->locked = $locked;
      }

      public static function asc(string $field): self
      {
          return new self($field, 'asc', false);
      }

      public static function desc(string $field): self
      {
          return new self($field, 'desc', false);
      }

      public static function locked(string $field, string $direction): self
      {
          return new self($field, $direction, true);
      }

      public function getField(): string
      {
          return $this->field;
      }

      public function getDirection(string $requestedDirection, bool $directionSpecified): string
      {
          if ($this->locked) {
              return $this->defaultDirection;
          }

          if (!$directionSpecified && $this->defaultDirection) {
              return $this->defaultDirection;
          }

          return $requestedDirection;
      }

      public function isLocked(): bool
      {
          return $this->locked;
      }
  }

Usage Examples:

  'sortMap' => [
      'newest' => [
          SortField::desc('created'),  // Default desc, toggleable
          SortField::asc('title'),     // Default asc, toggleable
      ],
      'popular' => [
          SortField::locked('score', 'desc'),  // Always desc
          'author',  // Still support strings for BC
      ],
  ]

@rochamarcelo
Copy link
Contributor

rochamarcelo commented Sep 12, 2025

Yes, I like that. Maybe add an interface for it with methods getField and isLocked.

I'm not sure if the methods desc and asc are necessary, but the names could be clear, something like defaultDesc and defaultAsc.

@josbeir
Copy link
Contributor

josbeir commented Sep 12, 2025

We could maybe also create factory class for building the sortmap then. This would allow for a more strict and readable way for defining them.

$factory = SortMapFactory::create()
    ->field('title')
    ->field('created')
    ->field('rank', locked: true, default: 'desc')
    ->combo('newest', [
        SortField::desc('created'),
        SortField::asc('title'),
    ])
    ->combo('popular', [
        SortField::desc('score'),
        SortField::asc('comments_count'),
    ])
    ->combo('recently-updated', [
        SortField::desc('modified'),
        SortField::asc('title'),
    ])
    ->raw('alpha-group', [
        'group_name' => 'asc',
        'name' => 'asc',
    ]);

@dereuromark
Copy link
Member Author

dereuromark commented Sep 14, 2025

I added factory and SortField now with tests.
Also deprecated old sortableFields now.

Comment on lines +796 to +816
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;
} elseif (str_ends_with($value, '!')) {
// Associative array: check for locked (!) or default 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;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we have both the data-object pattern, and array shaped data format. Can we pick one API? This is net new code, we shouldn't start off with two different APIs.

To me the data-object pattern is easier to operate as I can use LSP to explore it more easily. It is also less ambiguous and easier to evolve and extend.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine with only factories and objects.
We can then remove all array configs.

/**
* Represents a sort field configuration for pagination.
*/
class SortField
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think classes like this are a great way for use to provide stronger typing and easier to operate and remember APIs.

Comment on lines +440 to +448
// 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];
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this if we're introducing SortField?

Comment on lines +47 to +59
$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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need two different builder patterns? Can we pick one instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which ones u prefer?

Copy link
Contributor

@josbeir josbeir Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. i like the SortMapFactory,, i'm just not sure about the fluent style of building. This is something that is not used in other cake api's i think and seems a bit too locked in.

  2. I'd additionally love to see something that would expose an instance of the factory through a callable, this way you would not need to call ->build()

'sortMap' => function(Builder $builder) {
  return $builder
      ->key('newest', SortField::desc('title'), SortField::asc('title'))
      ->key('oldest', SortField::asc('created'), SortField::asc('title'))
      ->key('popular',
            SortField::desc('score', locked: true),
            SortField::desc('view'),
           ....
           ....
     ); 
});
  1. i would not use the word 'sort' (sortKey) in method names, This seems a bit redundant given the Factory shares the same name :-)

  2. Maybe we should just use use 'sorts as key to define the sortMap ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which ones u prefer?

I find the factory easier to understand when reading code. The fluent builder is interesting, but the method chains are dense, and I could see a stray sortKey() call causing unexpected results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants