Skip to content

Commit

Permalink
Merge pull request jfinkels#631 from jfinkels/case-insensitive-sorting
Browse files Browse the repository at this point in the history
Allows client to request case-insensitive sorting
  • Loading branch information
jfinkels authored Mar 25, 2017
2 parents 2a878e9 + a676e4d commit 8e32477
Show file tree
Hide file tree
Showing 8 changed files with 74 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
3 changes: 2 additions & 1 deletion docs/sorting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Sorting
Clients can sort according to the sorting protocol described in the `Sorting
<http://jsonapi.org/format/#fetching-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
Expand Down
19 changes: 13 additions & 6 deletions flask_restless/search/drivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions flask_restless/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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')
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion flask_restless/views/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion flask_restless/views/relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions flask_restless/views/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions tests/test_fetching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down

0 comments on commit 8e32477

Please sign in to comment.