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
@@ -39,6 +39,7 @@
 
 import logging
 import re
+import six
 import time
 import urllib
 
@@ -507,10 +508,20 @@ def searchResults(self, REQUEST=None, **kw):
         if not show_inactive and not self.allow_inactive(kw):
             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
@@ -2,6 +2,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 setRoles
 from plone.app.testing import TEST_USER_ID
@@ -9,21 +10,23 @@
 from plone.indexer.wrapper import IndexableObjectWrapper
 from plone.uuid.interfaces import IAttributeUUID
 from plone.uuid.interfaces import IUUID
-from Products.CMFCore.permissions import AccessInactivePortalContent
 from Products.CMFCore.indexing import processQueue
+from Products.CMFCore.permissions import AccessInactivePortalContent
 from Products.CMFPlone.CatalogTool import CatalogTool
 from Products.CMFPlone.CatalogTool import is_folderish
 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'
 
@@ -542,6 +545,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'.