Skip to content

Commit 0ba9539

Browse files
authored
Improved documentation and tests of ordering filter (#1334)
1 parent f735a9b commit 0ba9539

10 files changed

Lines changed: 200 additions & 141 deletions

File tree

‎docs/usage.md‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,28 @@ simply don't use this filter backend.
133133

134134
#### OrderingFilter
135135

136-
`OrderingFilter` implements the [JSON:API `sort`](https://jsonapi.org/format/#fetching-sorting) and uses
137-
DRF's [ordering filter](https://www.django-rest-framework.org/api-guide/filtering/#orderingfilter).
136+
`OrderingFilter` implements [JSON:API `sorting`](https://jsonapi.org/format/#fetching-sorting) and uses
137+
Django REST framework's [ordering filter](https://www.django-rest-framework.org/api-guide/filtering/#orderingfilter) as a basis.
138+
139+
Define `ordering_fields` on your view to define what fields are supported for sorting. If not defined per default only direct readable fields
140+
will be allowed.
141+
142+
Example:
143+
144+
```python
145+
class BookViewSet(views.ModelViewSet):
146+
queryset = Book.objects.all()
147+
ordering_fields = ['name', 'author__name']
148+
```
149+
150+
For sorting of relationship fields the dot notation can be used. For instance in above example to
151+
sort by name of author, `sort` query parameter needs to be `?sort=author.name`.
152+
153+
Unlike in DRF, it needs to be noted that in JSON:API when the `sort` query parameter
154+
contains invalid field names, a `400 Bad Request` will be returned.
155+
156+
A error response will look like the following.
138157

139-
Per the JSON:API specification, "If the server does not support sorting as specified in the query parameter `sort`,
140-
it **MUST** return `400 Bad Request`." For example, for `?sort=abc,foo,def` where `foo` is a valid
141-
field name and the other two are not valid:
142158
```json
143159
{
144160
"errors": [
@@ -153,9 +169,6 @@ field name and the other two are not valid:
153169
}
154170
```
155171

156-
If you want to silently ignore bad sort fields, just use `rest_framework.filters.OrderingFilter` and set
157-
`ordering_param` to `sort`.
158-
159172
#### DjangoFilterBackend
160173

161174
`DjangoFilterBackend` implements a Django ORM-style [JSON:API `filter`](https://jsonapi.org/format/#fetching-filtering)

‎example/tests/test_filters.py‎

Lines changed: 1 addition & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from rest_framework.reverse import reverse
22
from rest_framework.test import APITestCase
33

4-
from example.models import Blog, Entry
4+
from example.models import Entry
55

66

77
class DJATestFilters(APITestCase):
@@ -13,112 +13,10 @@ class DJATestFilters(APITestCase):
1313

1414
def setUp(self):
1515
self.entries = Entry.objects.all()
16-
self.blogs = Blog.objects.all()
1716
self.url = reverse("nopage-entry-list")
1817
self.fs_url = reverse("filterset-entry-list")
1918
self.no_fs_url = reverse("nofilterset-entry-list")
2019

21-
def test_sort(self):
22-
"""
23-
test sort
24-
"""
25-
response = self.client.get(self.url, data={"sort": "headline"})
26-
self.assertEqual(
27-
response.status_code, 200, msg=response.content.decode("utf-8")
28-
)
29-
dja_response = response.json()
30-
headlines = [c["attributes"]["headline"] for c in dja_response["data"]]
31-
sorted_headlines = sorted(headlines)
32-
self.assertEqual(headlines, sorted_headlines)
33-
34-
def test_sort_reverse(self):
35-
"""
36-
confirm switching the sort order actually works
37-
"""
38-
response = self.client.get(self.url, data={"sort": "-headline"})
39-
self.assertEqual(
40-
response.status_code, 200, msg=response.content.decode("utf-8")
41-
)
42-
dja_response = response.json()
43-
headlines = [c["attributes"]["headline"] for c in dja_response["data"]]
44-
sorted_headlines = sorted(headlines)
45-
self.assertNotEqual(headlines, sorted_headlines)
46-
47-
def test_sort_double_negative(self):
48-
"""
49-
what if they provide multiple `-`'s? It's OK.
50-
"""
51-
response = self.client.get(self.url, data={"sort": "--headline"})
52-
self.assertEqual(
53-
response.status_code, 200, msg=response.content.decode("utf-8")
54-
)
55-
dja_response = response.json()
56-
headlines = [c["attributes"]["headline"] for c in dja_response["data"]]
57-
sorted_headlines = sorted(headlines)
58-
self.assertNotEqual(headlines, sorted_headlines)
59-
60-
def test_sort_invalid(self):
61-
"""
62-
test sort of invalid field
63-
"""
64-
response = self.client.get(
65-
self.url, data={"sort": "nonesuch,headline,-not_a_field"}
66-
)
67-
self.assertEqual(
68-
response.status_code, 400, msg=response.content.decode("utf-8")
69-
)
70-
dja_response = response.json()
71-
self.assertEqual(
72-
dja_response["errors"][0]["detail"],
73-
"invalid sort parameters: nonesuch,-not_a_field",
74-
)
75-
76-
def test_sort_camelcase(self):
77-
"""
78-
test sort of camelcase field name
79-
"""
80-
response = self.client.get(self.url, data={"sort": "bodyText"})
81-
self.assertEqual(
82-
response.status_code, 200, msg=response.content.decode("utf-8")
83-
)
84-
dja_response = response.json()
85-
blog_ids = [(c["attributes"]["bodyText"] or "") for c in dja_response["data"]]
86-
sorted_blog_ids = sorted(blog_ids)
87-
self.assertEqual(blog_ids, sorted_blog_ids)
88-
89-
def test_sort_underscore(self):
90-
"""
91-
test sort of underscore field name
92-
Do we allow this notation in a search even if camelcase is in effect?
93-
"Be conservative in what you send, be liberal in what you accept"
94-
-- https://en.wikipedia.org/wiki/Robustness_principle
95-
"""
96-
response = self.client.get(self.url, data={"sort": "body_text"})
97-
self.assertEqual(
98-
response.status_code, 200, msg=response.content.decode("utf-8")
99-
)
100-
dja_response = response.json()
101-
blog_ids = [(c["attributes"]["bodyText"] or "") for c in dja_response["data"]]
102-
sorted_blog_ids = sorted(blog_ids)
103-
self.assertEqual(blog_ids, sorted_blog_ids)
104-
105-
def test_sort_related(self):
106-
"""
107-
test sort via related field using JSON:API path `.` and django orm `__` notation.
108-
ORM relations must be predefined in the View's .ordering_fields attr
109-
"""
110-
for datum in ("blog__id", "blog.id"):
111-
response = self.client.get(self.url, data={"sort": datum})
112-
self.assertEqual(
113-
response.status_code, 200, msg=response.content.decode("utf-8")
114-
)
115-
dja_response = response.json()
116-
blog_ids = [
117-
c["relationships"]["blog"]["data"]["id"] for c in dja_response["data"]
118-
]
119-
sorted_blog_ids = sorted(blog_ids)
120-
self.assertEqual(blog_ids, sorted_blog_ids)
121-
12220
def test_filter_exact(self):
12321
"""
12422
filter for an exact match

‎requirements/requirements-testing.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
factory-boy==3.3.3
22
Faker==40.37.0
3+
inline-snapshot==0.36.0
34
pytest==9.1.1
45
pytest-cov==7.1.0
56
pytest-django==4.14.0

‎tests/conftest.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def use_rest_framework_json_api_defaults(settings):
3434

3535
@pytest.fixture
3636
def model(db):
37-
return BasicModel.objects.create(text="Model")
37+
return BasicModel.objects.create(text="Model", long_text="LongText")
3838

3939

4040
@pytest.fixture

‎tests/models.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class Meta:
1313

1414
class BasicModel(DJAModel):
1515
text = models.CharField(max_length=100)
16+
long_text = models.CharField(max_length=100)
1617

1718
class Meta:
1819
ordering = ("id",)

‎tests/serializers.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
class BasicModelSerializer(serializers.ModelSerializer):
1616
class Meta:
17-
fields = ("text",)
17+
fields = ("text", "long_text")
1818
model = BasicModel
1919

2020

‎tests/test_filters.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import pytest
2+
from django.urls import reverse
3+
from rest_framework import status
4+
5+
from tests.models import BasicModel, ForeignKeySource, ForeignKeyTarget
6+
7+
8+
@pytest.mark.urls("tests.urls")
9+
class TestOrderingFilter:
10+
@pytest.mark.parametrize(
11+
"sort_param,expected",
12+
[
13+
# ascending sort
14+
("text", ["1", "2", "3"]),
15+
# descending sort
16+
("-text", ["3", "2", "1"]),
17+
# descending sort with two dashes should work
18+
("--text", ["3", "2", "1"]),
19+
],
20+
)
21+
def test_sort(self, db, client, sort_param, expected):
22+
BasicModel.objects.create(text="1")
23+
BasicModel.objects.create(text="3")
24+
BasicModel.objects.create(text="2")
25+
26+
url = reverse("basicmodel-list")
27+
response = client.get(url, {"sort": sort_param, "page[size]": 3})
28+
result = response.json()
29+
30+
assert response.status_code == status.HTTP_200_OK
31+
assert [d["attributes"]["text"] for d in result["data"]] == expected
32+
33+
@pytest.mark.parametrize(
34+
"sort_param,error",
35+
[
36+
(
37+
"nonesuch,text,-not-a-field",
38+
["invalid sort parameters: nonesuch,-not-a-field"],
39+
)
40+
],
41+
)
42+
def test_sort_invalid(self, db, client, sort_param, error):
43+
url = reverse("basicmodel-list")
44+
response = client.get(url, {"sort": sort_param, "page[size]": 3})
45+
result = response.json()
46+
47+
assert response.status_code == status.HTTP_400_BAD_REQUEST
48+
assert [e["detail"] for e in result["errors"]] == error
49+
50+
@pytest.mark.parametrize(
51+
"format_type,sort_param,expected",
52+
[
53+
(False, "long_text", ["1", "2", "3"]),
54+
(False, "-long_text", ["3", "2", "1"]),
55+
("underscore", "long_text", ["1", "2", "3"]),
56+
("underscore", "-long_text", ["3", "2", "1"]),
57+
("dasherize", "long-text", ["1", "2", "3"]),
58+
("dasherize", "-long-text", ["3", "2", "1"]),
59+
("camelize", "longText", ["1", "2", "3"]),
60+
("camelize", "-longText", ["3", "2", "1"]),
61+
("capitalize", "LongText", ["1", "2", "3"]),
62+
("capitalize", "-LongText", ["3", "2", "1"]),
63+
],
64+
)
65+
def test_sort_format(self, db, client, settings, format_type, sort_param, expected):
66+
settings.JSON_API_FORMAT_FIELD_NAMES = format_type
67+
68+
BasicModel.objects.create(long_text="1")
69+
BasicModel.objects.create(long_text="3")
70+
BasicModel.objects.create(long_text="2")
71+
72+
url = reverse("basicmodel-list")
73+
response = client.get(url, {"sort": sort_param, "page[size]": 3})
74+
result = response.json()
75+
76+
assert response.status_code == status.HTTP_200_OK
77+
values = [d["attributes"][sort_param.lstrip("-")] for d in result["data"]]
78+
assert values == expected
79+
80+
@pytest.mark.parametrize(
81+
"sort_param,expected",
82+
[
83+
("target.name,name", ["1", "2", "3"]),
84+
("-target.name,-name", ["3", "2", "1"]),
85+
("target__name,name", ["1", "2", "3"]),
86+
("-target__name,-name", ["3", "2", "1"]),
87+
],
88+
)
89+
def test_sort_related(self, db, client, sort_param, expected):
90+
ForeignKeySource.objects.create(
91+
name="3", target=ForeignKeyTarget.objects.create(name="3")
92+
)
93+
ForeignKeySource.objects.create(
94+
name="1", target=ForeignKeyTarget.objects.create(name="1")
95+
)
96+
ForeignKeySource.objects.create(
97+
name="2", target=ForeignKeyTarget.objects.create(name="1")
98+
)
99+
100+
url = reverse("foreignkeysource-list")
101+
response = client.get(url, {"sort": sort_param, "page[size]": 3})
102+
result = response.json()
103+
104+
assert response.status_code == status.HTTP_200_OK
105+
values = [d["attributes"]["name"] for d in result["data"]]
106+
assert values == expected

0 commit comments

Comments
 (0)