diff --git a/CHANGES b/CHANGES index ce805c36..5b649778 100644 --- a/CHANGES +++ b/CHANGES @@ -48,6 +48,7 @@ Not yet released. - :issue:`599`: fixes `unicode` bug using :func:`!urlparse.urljoin` with the `future`_ library in resource serialization. - :issue:`625`: adds schema metadata to root endpoint. +- :issue:`626`: allows the client to request case-insensitive sorting. .. _future: http://python-future.org/ diff --git a/docs/sorting.rst b/docs/sorting.rst index 12cbddfd..c1a30023 100644 --- a/docs/sorting.rst +++ b/docs/sorting.rst @@ -4,7 +4,8 @@ Sorting Clients can sort according to the sorting protocol described in the `Sorting `__ section of the JSON API specification. Sorting by a nullable attribute will cause resources with null -attributes to appear first. +attributes to appear first. The client can request case-insensitive sorting by +setting the query parameter ``ignorecase=1``. Clients can also request grouping by using the ``group`` query parameter. For example, if your database has two people with name ``'foo'`` and two people diff --git a/flask_restless/search/drivers.py b/flask_restless/search/drivers.py index 302039b7..feb5e306 100644 --- a/flask_restless/search/drivers.py +++ b/flask_restless/search/drivers.py @@ -29,7 +29,7 @@ def search_relationship(session, instance, relation, filters=None, sort=None, - group_by=None): + group_by=None, ignorecase=False): """Returns a filtered, sorted, and grouped SQLAlchemy query restricted to those objects related to a given instance. @@ -40,8 +40,8 @@ def search_relationship(session, instance, relation, filters=None, sort=None, ` `relation` is a string naming a to-many relationship of `instance`. - `filters`, `sort`, and `group_by` are identical to the corresponding - arguments of :func:`.search`. + `filters`, `sort`, `group_by`, and `ignorecase` are identical to the + corresponding arguments of :func:`.search`. """ model = get_model(instance) @@ -60,11 +60,12 @@ def search_relationship(session, instance, relation, filters=None, sort=None, query = query.filter(primary_key_value(related_model).in_(primary_keys)) return search(session, related_model, filters=filters, sort=sort, - group_by=group_by, _initial_query=query) + group_by=group_by, ignorecase=ignorecase, + _initial_query=query) def search(session, model, filters=None, sort=None, group_by=None, - _initial_query=None): + ignorecase=False, _initial_query=None): """Returns a filtered, sorted, and grouped SQLAlchemy query. `session` is the SQLAlchemy session in which to create the query. @@ -80,7 +81,9 @@ def search(session, model, filters=None, sort=None, group_by=None, `sort` is a list of pairs of the form ``(direction, fieldname)``, where ``direction`` is either '+' or '-' and ``fieldname`` is a string representing an attribute of the model or a dot-separated - relationship path (for example, 'owner.name'). + relationship path (for example, 'owner.name'). If `ignorecase` is + True, the sorting will be case-insensitive (so 'a' will precede 'B' + instead of the default behavior in which 'B' precedes 'a'). `group_by` is a list of dot-separated relationship paths on which to group the query results. @@ -113,11 +116,15 @@ def search(session, model, filters=None, sort=None, group_by=None, field_name, field_name_in_relation = field_name.split('.') relation_model = aliased(get_related_model(model, field_name)) field = getattr(relation_model, field_name_in_relation) + if ignorecase: + field = field.collate('NOCASE') direction = getattr(field, direction_name) query = query.join(relation_model) query = query.order_by(direction()) else: field = getattr(model, field_name) + if ignorecase: + field = field.collate('NOCASE') direction = getattr(field, direction_name) query = query.order_by(direction()) else: diff --git a/flask_restless/views/base.py b/flask_restless/views/base.py index 2070684b..0b300377 100644 --- a/flask_restless/views/base.py +++ b/flask_restless/views/base.py @@ -111,6 +111,9 @@ #: request. SORT_PARAM = 'sort' +#: The query parameter key that indicates whether sorting is case-insensitive. +IGNORECASE_PARAM = 'ignorecase' + #: The query parameter key that identifies grouping fields in a #: :http:method:`get` request. GROUP_PARAM = 'group' @@ -1205,6 +1208,7 @@ def collection_parameters(self, resource_id=None, relation_name=None): for value in sort.split(',')] else: sort = [] + ignorecase = bool(int(request.args.get(IGNORECASE_PARAM, '0'))) # Determine grouping options. group_by = request.args.get(GROUP_PARAM) @@ -1219,7 +1223,7 @@ def collection_parameters(self, resource_id=None, relation_name=None): except ValueError: raise SingleKeyError('failed to extract Boolean from parameter') - return filters, sort, group_by, single + return filters, sort, group_by, single, ignorecase class APIBase(ModelView): @@ -1601,7 +1605,7 @@ def _get_resource_helper(self, resource, primary_resource=None, def _get_collection_helper(self, resource=None, relation_name=None, filters=None, sort=None, group_by=None, - single=False): + ignorecase=False, single=False): if (resource is None) ^ (relation_name is None): raise ValueError('resource and relation must be both None or both' ' not None') @@ -1614,7 +1618,7 @@ def _get_collection_helper(self, resource=None, relation_name=None, search_ = partial(search, self.session, self.model) try: search_items = search_(filters=filters, sort=sort, - group_by=group_by) + group_by=group_by, ignorecase=ignorecase) except (FilterParsingError, FilterCreationError) as exception: detail = 'invalid filter object: {0}'.format(str(exception)) return error_response(400, cause=exception, detail=detail) diff --git a/flask_restless/views/function.py b/flask_restless/views/function.py index c3ef38e3..3f5417e1 100644 --- a/flask_restless/views/function.py +++ b/flask_restless/views/function.py @@ -124,7 +124,8 @@ def get(self): # Get the filtering, sorting, and grouping parameters. try: - filters, sort, group_by, single = self.collection_parameters() + filters, sort, group_by, single, ignorecase = \ + self.collection_parameters() except (TypeError, ValueError, OverflowError) as exception: detail = 'Unable to decode filter objects as JSON list' return error_response(400, cause=exception, detail=detail) diff --git a/flask_restless/views/relationships.py b/flask_restless/views/relationships.py index 939abe7b..6d440983 100644 --- a/flask_restless/views/relationships.py +++ b/flask_restless/views/relationships.py @@ -95,7 +95,7 @@ def get(self, resource_id, relation_name): return error_response(404, detail=detail) if is_like_list(primary_resource, relation_name): try: - filters, sort, group_by, single = \ + filters, sort, group_by, single, ignorecase = \ self.collection_parameters(resource_id=resource_id, relation_name=relation_name) except (TypeError, ValueError, OverflowError) as exception: diff --git a/flask_restless/views/resources.py b/flask_restless/views/resources.py index b259d232..f4fe1f4b 100644 --- a/flask_restless/views/resources.py +++ b/flask_restless/views/resources.py @@ -226,7 +226,7 @@ def _get_relation(self, resource_id, relation_name): """ try: - filters, sort, group_by, single = \ + filters, sort, group_by, single, ignorecase = \ self.collection_parameters(resource_id=resource_id, relation_name=relation_name) except (TypeError, ValueError, OverflowError) as exception: @@ -283,6 +283,7 @@ def _get_relation(self, resource_id, relation_name): relation_name=relation_name, filters=filters, sort=sort, group_by=group_by, + ignorecase=ignorecase, single=single) else: resource = getattr(primary_resource, relation_name) @@ -353,7 +354,8 @@ def _get_collection(self): """ try: - filters, sort, group_by, single = self.collection_parameters() + filters, sort, group_by, single, ignorecase = \ + self.collection_parameters() except (TypeError, ValueError, OverflowError) as exception: detail = 'Unable to decode filter objects as JSON list' return error_response(400, cause=exception, detail=detail) @@ -366,7 +368,8 @@ def _get_collection(self): single=single) return self._get_collection_helper(filters=filters, sort=sort, - group_by=group_by, single=single) + group_by=group_by, single=single, + ignorecase=ignorecase) def get(self, resource_id, relation_name, related_resource_id): """Returns the JSON document representing a resource or a collection of diff --git a/tests/test_fetching.py b/tests/test_fetching.py index 1f036d1a..afda73d9 100644 --- a/tests/test_fetching.py +++ b/tests/test_fetching.py @@ -376,6 +376,48 @@ def test_sorting_hybrid_expression(self): articles = document['data'] self.assertEqual(['1', '2'], list(map(itemgetter('id'), articles))) + def test_case_insensitive_sorting(self): + """Test for case-insensitive sorting. + + For more information, see GitHub issue #626. + + """ + person1 = self.Person(id=1, name=u'B') + person2 = self.Person(id=2, name=u'a') + self.session.add_all([person1, person2]) + self.session.commit() + query_string = {'sort': 'name', 'ignorecase': 1} + response = self.app.get('/api/person', query_string=query_string) + # The ASCII character code for the uppercase letter 'B' comes + # before the ASCII character code for the lowercase letter 'a', + # but in case-insensitive sorting, the 'a' should precede the + # 'B'. + document = loads(response.data) + person1, person2 = document['data'] + self.assertEqual(person1['id'], u'2') + self.assertEqual(person1['attributes']['name'], u'a') + self.assertEqual(person2['id'], u'1') + self.assertEqual(person2['attributes']['name'], u'B') + + def test_case_insensitive_sorting_relationship_attributes(self): + """Test for case-insensitive sorting on relationship attributes.""" + person1 = self.Person(id=1, name=u'B') + person2 = self.Person(id=2, name=u'a') + article1 = self.Article(id=1, author=person1) + article2 = self.Article(id=2, author=person2) + self.session.add_all([article1, article2, person1, person2]) + self.session.commit() + query_string = {'sort': 'author.name', 'ignorecase': 1} + response = self.app.get('/api/article', query_string=query_string) + # The ASCII character code for the uppercase letter 'B' comes + # before the ASCII character code for the lowercase letter 'a', + # but in case-insensitive sorting, the 'a' should precede the + # 'B'. + document = loads(response.data) + article1, article2 = document['data'] + self.assertEqual(article1['id'], u'2') + self.assertEqual(article2['id'], u'1') + class TestFetchResource(ManagerTestBase):