Repository: plone/Products.CMFPlone

Issue:
Catalog sort_on on multiple columns is broken
ZCatalog can handle multiple sort_indexes. 

Products.ZCatalog.Catalog.py [1014] 
accepts a list of search indexes:
```
    def _getSortIndex(self, args):
        """Returns a list of search index objects or None."""
        sort_index_names = self._get_sort_attr("on", args)
        if sort_index_names is not None:
            # self.indexes is always a dict, so get() w/ 1 arg works
            sort_indexes = []
            if not isinstance(sort_index_names, (list, tuple)):
                sort_index_names = [sort_index_names]
            for name in sort_index_names:
                sort_index = self.indexes.get(name)
```
But commit 0909383eb2bbc8d31794a7dbf4392eec6236be22 breaks this 

CatalogTool.py [461]
```
        sort_on = kw.get('sort_on')
        if sort_on and sort_on not in self.indexes():
            # I get crazy sort_ons like '194' or 'null'.
           kw.pop('sort_on')
```
This assumes that sort_on is a index_name and fails if it is a list of index names.

Also the QueryBuilder has the same flaw:
plone.app.querystring.queryparser [53]
```
     # Add sorting (sort_on and sort_order) to the query
    if sort_on:
        catalog = getToolByName(context, 'portal_catalog')
        # I get crazy sort_ons like '194' or 'null'.
        if sort_on in catalog.indexes():
            query['sort_on'] = sort_on
            if sort_order:
                query['sort_order'] = sort_order
```
The later can be circumvented by using the custom-query parameter, but not the former.


### What I did:

results = catalog(**{ ...,  'sort_on' : ['title','description']], ... })

### What I expect to happen:

The result should be sorted after both indexes.

### What actually happened:

The result is not sorted at all.

### What version of Plone/ Addons I am using:

5.1

Shall I make a pull request?



Discussion:
Here a simple fix for the impatient


```
        # remove sort_on fields not backed by an index
        sort_on = kw.get('sort_on')
        if sort_on :
            indexes = self.indexes()
            if not isinstance(sort_on, (list, tuple)):
                sort_on = [sort_on]
            for sort_idx in sort_on:
                if sort_idx not in indexes:
                    sort_on.delete(sort_idx)
            kw['sort_on'] = sort_on
```
@volkerjaenisch I can confirm your analysis, thanks a lot :)
I will try to make a PR to fix this!

Resolving patch:
diff --git a/Products/CMFPlone/CatalogTool.py b/Products/CMFPlone/CatalogTool.py
--- a/Products/CMFPlone/CatalogTool.py
+++ b/Products/CMFPlone/CatalogTool.py
@@ -1,12 +1,6 @@
-import logging
-import re
-import time
-import urllib
-
 from AccessControl import ClassSecurityInfo
 from AccessControl.PermissionRole import rolesForPermissionOn
-from AccessControl.Permissions import (
-    manage_zcatalog_entries as ManageZCatalogEntries)
+from AccessControl.Permissions import manage_zcatalog_entries as ManageZCatalogEntries  # noqa
 from AccessControl.Permissions import search_zcatalog as SearchZCatalog
 from Acquisition import aq_base
 from Acquisition import aq_inner
@@ -16,6 +10,9 @@
 from BTrees.Length import Length
 from DateTime import DateTime
 from OFS.interfaces import IOrderedContainer
+from plone.i18n.normalizer.base import mapUnicode
+from plone.indexer import indexer
+from plone.indexer.interfaces import IIndexableObject
 from Products.CMFCore.CatalogTool import CatalogTool as BaseTool
 from Products.CMFCore.CatalogTool import _mergedLocalRoles
 from Products.CMFCore.permissions import AccessInactivePortalContent
@@ -23,22 +20,25 @@
 from Products.CMFCore.utils import _getAuthenticatedUser
 from Products.CMFCore.utils import getToolByName
 from Products.CMFPlone import DISCUSSION_ANNOTATION_KEY
-from Products.CMFPlone.PloneBaseTool import PloneBaseTool
 from Products.CMFPlone.interfaces import INonStructuralFolder
 from Products.CMFPlone.interfaces import IPloneCatalogTool
+from Products.CMFPlone.PloneBaseTool import PloneBaseTool
 from Products.CMFPlone.utils import base_hasattr
 from Products.CMFPlone.utils import safe_callable
 from Products.CMFPlone.utils import safe_unicode
 from Products.ZCatalog.ZCatalog import ZCatalog
-from plone.i18n.normalizer.base import mapUnicode
-from plone.indexer import indexer
-from plone.indexer.interfaces import IIndexableObject
 from zope.annotation.interfaces import IAnnotations
 from zope.component import queryMultiAdapter
-from zope.interface import Interface
 from zope.interface import implementer
+from zope.interface import Interface
 from zope.interface import providedBy
 
+import logging
+import re
+import six
+import time
+import urllib
+
 
 logger = logging.getLogger('Plone')
 
@@ -387,10 +387,20 @@ def searchResults(self, REQUEST=None, **kw):
            and not _checkPermission(AccessInactivePortalContent, self):
             kw['effectiveRange'] = DateTime()
 
-        sort_on = kw.get('sort_on')
-        if sort_on and sort_on not in self.indexes():
-            # I get crazy sort_ons like '194' or 'null'.
-            kw.pop('sort_on')
+        # filter out invalid sort_on indexes
+        sort_on = kw.get('sort_on') or []
+        if isinstance(sort_on, six.string_types):
+            sort_on = [sort_on]
+        valid_indexes = self.indexes()
+        try:
+            sort_on = [idx for idx in sort_on if idx in valid_indexes]
+        except TypeError:
+            # sort_on is not iterable
+            sort_on = []
+        if not sort_on:
+            kw.pop('sort_on', None)
+        else:
+            kw['sort_on'] = sort_on
 
         return ZCatalog.searchResults(self, REQUEST, **kw)
 


Test patch:
diff --git a/Products/CMFPlone/tests/testCatalogTool.py b/Products/CMFPlone/tests/testCatalogTool.py
--- a/Products/CMFPlone/tests/testCatalogTool.py
+++ b/Products/CMFPlone/tests/testCatalogTool.py
@@ -1,6 +1,7 @@
 # -*- encoding: utf-8 -*-
 from Acquisition import aq_base
 from DateTime import DateTime
+from functools import partial
 from OFS.ObjectManager import REPLACEABLE
 from plone.app.testing import TEST_USER_ID
 from plone.app.testing import TEST_USER_NAME
@@ -13,14 +14,16 @@
 from Products.CMFPlone.tests import dummy
 from Products.CMFPlone.tests.PloneTestCase import PloneTestCase
 from Products.CMFPlone.tests.utils import folder_position
+from z3c.form.interfaces import IFormLayer
 from zope.event import notify
 from zope.interface import alsoProvides
 from zope.lifecycleevent import ObjectCreatedEvent
-from z3c.form.interfaces import IFormLayer
+
 import transaction
 import unittest
 import zope.interface
 
+
 user2 = 'u2'
 group2 = 'g2'
 
@@ -519,6 +522,36 @@ def afterSetUp(self):
         self.folder.doc5.reindexObject()
         self.folder.doc6.reindexObject()
 
+    def testSortMultipleColumns(self):
+        path = '/'.join(self.folder.getPhysicalPath())
+        query = partial(self.catalog, path=path)
+        brains = query(sort_on=['portal_type', 'sortable_title'])
+        self.assertListEqual(
+            [brain.getPath() for brain in brains],
+            [
+                '/plone/Members/test_user_1_/doc2',
+                '/plone/Members/test_user_1_/doc3',
+                '/plone/Members/test_user_1_/doc',
+                '/plone/Members/test_user_1_/doc5',
+                '/plone/Members/test_user_1_/doc6',
+                '/plone/Members/test_user_1_/doc4',
+                '/plone/Members/test_user_1_'
+            ],
+        )
+        brains = query(sort_on=['portal_type', 'getId'])
+        self.assertListEqual(
+            [brain.getPath() for brain in brains],
+            [
+                '/plone/Members/test_user_1_/doc',
+                '/plone/Members/test_user_1_/doc2',
+                '/plone/Members/test_user_1_/doc3',
+                '/plone/Members/test_user_1_/doc4',
+                '/plone/Members/test_user_1_/doc5',
+                '/plone/Members/test_user_1_/doc6',
+                '/plone/Members/test_user_1_',
+            ]
+        )
+
     def testUnknownSortOnIsIgnored(self):
         # You should not get a CatalogError when an invalid sort_on is passed.
         # I get crazy sort_ons like '194' or 'null'.