From 5707e3e83ffc61b004f6740bb194ef0a263f9820 Mon Sep 17 00:00:00 2001
From: Alberto Donato <alberto.donato@gmail.com>
Date: Mon, 19 Nov 2018 11:45:19 +0100
Subject: [PATCH 1/2] contrib/alchemy: Use sort_attribute as default sort for
 instances, support DESC sorting

---
 flask_potion/contrib/alchemy/manager.py       | 15 +++-
 .../alchemy/test_manager_sqlalchemy.py        | 75 +++++++++++++++++++
 2 files changed, 88 insertions(+), 2 deletions(-)

diff --git a/flask_potion/contrib/alchemy/manager.py b/flask_potion/contrib/alchemy/manager.py
index 867e90d..b7d99db 100644
--- a/flask_potion/contrib/alchemy/manager.py
+++ b/flask_potion/contrib/alchemy/manager.py
@@ -45,7 +45,8 @@ def _init_model(self, resource, model, meta):
             self.id_attribute = mapper.primary_key[0].name
 
         self.id_field = self._get_field_from_column_type(self.id_column, self.id_attribute, io="r")
-        self.default_sort_expression = self.id_column.asc()
+        self.default_sort_expression = self._get_sort_expression(
+            model, meta, self.id_column)
 
         fs = resource.schema
         if meta.include_id:
@@ -86,6 +87,13 @@ def _init_model(self, resource, model, meta):
                     fs.required.add(name)
                 fs.set(name, self._get_field_from_column_type(column, name, io=io))
 
+    def _get_sort_expression(self, model, meta, id_column):
+        if meta.sort_attribute is None:
+            return id_column.asc()
+        if meta.sort_attribute.startswith('-'):
+            return getattr(model, meta.sort_attribute[1:]).desc()
+        return getattr(model, meta.sort_attribute).asc()
+
     def _get_field_from_column_type(self, column, attribute, io="rw"):
         args = ()
         kwargs = {}
@@ -195,7 +203,10 @@ def _query_order_by(self, query, sort=None):
             if isinstance(field, fields.ToOne):
                 target_alias = aliased(field.target.meta.model)
                 query = query.outerjoin(target_alias, column).reset_joinpoint()
-                column = getattr(target_alias, field.target.meta.sort_attribute or field.target.manager.id_attribute)
+                sort_attribute = field.target.meta.sort_attribute
+                if sort_attribute and sort_attribute.startswith('-'):
+                    sort_attribute = sort_attribute[1:]
+                column = getattr(target_alias, sort_attribute or field.target.manager.id_attribute)
 
             order_clauses.append(column.desc() if reverse else column.asc())
 
diff --git a/tests/contrib/alchemy/test_manager_sqlalchemy.py b/tests/contrib/alchemy/test_manager_sqlalchemy.py
index 57c4287..ee35ee1 100644
--- a/tests/contrib/alchemy/test_manager_sqlalchemy.py
+++ b/tests/contrib/alchemy/test_manager_sqlalchemy.py
@@ -448,3 +448,78 @@ class Meta:
             "$id": 1,
             "username": "foo"
         }, response.json)
+
+
+class SQLAlchemySortTestCase(BaseTestCase):
+
+    def setUp(self):
+        super(SQLAlchemySortTestCase, self).setUp()
+        self.app.config['SQLALCHEMY_ENGINE'] = 'sqlite://'
+        self.api = Api(self.app)
+        self.sa = sa = SQLAlchemy(
+            self.app, session_options={"autoflush": False})
+
+        class Type(sa.Model):
+            id = sa.Column(sa.Integer, primary_key=True)
+            name = sa.Column(sa.String(60), nullable=False)
+
+        class Machine(sa.Model):
+            id = sa.Column(sa.Integer, primary_key=True)
+            name = sa.Column(sa.String(60), nullable=False)
+
+            type_id = sa.Column(sa.Integer, sa.ForeignKey(Type.id))
+            type = sa.relationship(Type, foreign_keys=[type_id])
+
+        sa.create_all()
+
+        class MachineResource(ModelResource):
+            class Meta:
+                model = Machine
+
+            class Schema:
+                type = fields.ToOne('type')
+
+        class TypeResource(ModelResource):
+            class Meta:
+                model = Type
+                sort_attribute = '-name'
+
+        self.MachineResource = MachineResource
+        self.TypeResource = TypeResource
+
+        self.api.add_resource(MachineResource)
+        self.api.add_resource(TypeResource)
+
+    def test_default_sorting_with_desc(self):
+        self.client.post('/type', data={"name": "aaa"})
+        self.client.post('/type', data={"name": "ccc"})
+        self.client.post('/type', data={"name": "bbb"})
+        response = self.client.get('/type')
+        self.assert200(response)
+        self.assertJSONEqual(
+            [{'$uri': '/type/2', 'name': 'ccc'},
+             {'$uri': '/type/3', 'name': 'bbb'},
+             {'$uri': '/type/1', 'name': 'aaa'}],
+            response.json)
+
+    def test_sort_by_related_field(self):
+        response = self.client.post('/type', data={"name": "aaa"})
+        self.assert200(response)
+        aaa_uri = response.json["$uri"]
+        response = self.client.post('/type', data={"name": "bbb"})
+        self.assert200(response)
+        bbb_uri = response.json["$uri"]
+        self.client.post(
+            '/machine', data={"name": "foo", "type": {"$ref": aaa_uri}})
+        self.assert200(response)
+        self.client.post(
+            '/machine', data={"name": "bar", "type": {"$ref": bbb_uri}})
+        self.assert200(response)
+        response = self.client.get('/machine?sort={"type": true}')
+        self.assert200(response)
+        type_uris = [entry['type']['$ref'] for entry in response.json]
+        self.assertTrue(type_uris, [bbb_uri, aaa_uri])
+        response = self.client.get('/machine?sort={"type": false}')
+        self.assert200(response)
+        type_uris = [entry['type']['$ref'] for entry in response.json]
+        self.assertTrue(type_uris, [bbb_uri, aaa_uri])

From b39e5203c2aa406e7bf94decc29897dd5396447b Mon Sep 17 00:00:00 2001
From: Alberto Donato <alberto.donato@gmail.com>
Date: Thu, 29 Nov 2018 10:17:19 +0200
Subject: [PATCH 2/2] accept  Meta.sort_attribute as a tuple

---
 docs/resources.rst                            |  4 ----
 flask_potion/contrib/alchemy/manager.py       | 17 ++++++++++-------
 flask_potion/resource.py                      | 19 ++++++++++++++++++-
 .../alchemy/test_manager_sqlalchemy.py        |  2 +-
 4 files changed, 29 insertions(+), 13 deletions(-)

diff --git a/docs/resources.rst b/docs/resources.rst
index a59c988..b769794 100644
--- a/docs/resources.rst
+++ b/docs/resources.rst
@@ -19,10 +19,6 @@ for the resource.
 
 :class:`ModelResource` is written for create, read, update, delete actions on collections of items matching the resource schema.
 
-A data store connection is maintained by a :class:`manager.Manager` instance.
-The manager class can be specified in ``Meta.manager``; if no manager is specified, ``Api.default_manager`` is used.
-Managers are configured through attributes in ``Meta``. Most managers expect a *model* to be defined under ``Meta.model``.
-
 .. autoclass:: ModelResource
     :members:
 
diff --git a/flask_potion/contrib/alchemy/manager.py b/flask_potion/contrib/alchemy/manager.py
index b7d99db..0c09eb8 100644
--- a/flask_potion/contrib/alchemy/manager.py
+++ b/flask_potion/contrib/alchemy/manager.py
@@ -90,9 +90,10 @@ def _init_model(self, resource, model, meta):
     def _get_sort_expression(self, model, meta, id_column):
         if meta.sort_attribute is None:
             return id_column.asc()
-        if meta.sort_attribute.startswith('-'):
-            return getattr(model, meta.sort_attribute[1:]).desc()
-        return getattr(model, meta.sort_attribute).asc()
+
+        attr_name, reverse = meta.sort_attribute
+        attr = getattr(model, attr_name)
+        return attr.desc() if reverse else attr.asc()
 
     def _get_field_from_column_type(self, column, attribute, io="rw"):
         args = ()
@@ -203,10 +204,12 @@ def _query_order_by(self, query, sort=None):
             if isinstance(field, fields.ToOne):
                 target_alias = aliased(field.target.meta.model)
                 query = query.outerjoin(target_alias, column).reset_joinpoint()
-                sort_attribute = field.target.meta.sort_attribute
-                if sort_attribute and sort_attribute.startswith('-'):
-                    sort_attribute = sort_attribute[1:]
-                column = getattr(target_alias, sort_attribute or field.target.manager.id_attribute)
+                sort_attribute = None
+                if field.target.meta.sort_attribute:
+                    sort_attribute, _ = field.target.meta.sort_attribute
+                column = getattr(
+                    target_alias,
+                    sort_attribute or field.target.manager.id_attribute)
 
             order_clauses.append(column.desc() if reverse else column.asc())
 
diff --git a/flask_potion/resource.py b/flask_potion/resource.py
index c0cff3d..5ce8f95 100644
--- a/flask_potion/resource.py
+++ b/flask_potion/resource.py
@@ -212,11 +212,28 @@ def __new__(mcs, name, bases, members):
             if 'model' in changes or 'model' in meta and 'manager' in changes:
                 if meta.manager is not None:
                     class_.manager = meta.manager(class_, meta.model)
+
+        sort_attribute = meta.get('sort_attribute')
+        if sort_attribute is not None and isinstance(sort_attribute, str):
+            meta.sort_attribute = sort_attribute, False
+
         return class_
 
 
 class ModelResource(six.with_metaclass(ModelResourceMeta, Resource)):
     """
+    :class:`Meta` class attributes:
+
+    =====================  ==============================  ==============================================================================
+    Attribute name         Default                         Description
+    =====================  ==============================  ==============================================================================
+    manager                ``Api.default_manager``         A data store connection is maintained by a :class:`manager.Manager` instance.
+                                                           Managers are configured through attributes in ``Meta``. Most managers expect
+                                                           a *model* to be defined under ``Meta.model``.
+    sort_attribute         None                            The field used to sort the list in the `instances` endpoint. Can be the
+                                                           field name as ``string`` or a ``tuple`` with the field name and a boolean
+                                                           for ``reverse`` (defaults to ``False``).
+    =====================  ==============================  ==============================================================================
 
     .. method:: create
 
@@ -322,4 +339,4 @@ class Meta:
             RefKey(),
             IDKey()
         )
-        natural_key = None
\ No newline at end of file
+        natural_key = None
diff --git a/tests/contrib/alchemy/test_manager_sqlalchemy.py b/tests/contrib/alchemy/test_manager_sqlalchemy.py
index ee35ee1..df55fb0 100644
--- a/tests/contrib/alchemy/test_manager_sqlalchemy.py
+++ b/tests/contrib/alchemy/test_manager_sqlalchemy.py
@@ -482,7 +482,7 @@ class Schema:
         class TypeResource(ModelResource):
             class Meta:
                 model = Type
-                sort_attribute = '-name'
+                sort_attribute = ('name', True)
 
         self.MachineResource = MachineResource
         self.TypeResource = TypeResource