-
-
Notifications
You must be signed in to change notification settings - Fork 565
Adding OrderingSchema for ordering QuerySets #1291
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
f907b25
ab7ed55
414225e
3b75169
ae370c8
726cc46
e3f3ed8
8bd6e9e
9a836db
dbbdb27
1156499
ae21e04
747e966
d5855fe
228c0c7
d61b663
5e4ebd5
43ed121
91011f8
157fdd3
eef8e8b
9144074
8406127
240848b
2d76aa9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| # Ordering | ||
|
|
||
| If you want to allow the user to order your querysets by a number of different attributes, you can use the provided class `OrderingSchema`. `OrderingSchema`, as a regular `Schema`, it uses all the | ||
| necessary features from Pydantic, and adds some some bells and whistles that will help use transform it into the usual Django queryset ordering. | ||
|
|
||
| You can start using it, importing the `OrderingSchema` and using it in your API handler in conjunction with `Query`: | ||
|
|
||
| ```python hl_lines="4" | ||
| from ninja import OrderingSchema | ||
|
|
||
| @api.get("/books") | ||
| def list_books(request, ordering: OrderingSchema = Query(...)): | ||
| books = Book.objects.all() | ||
| books = ordering.sort(books) | ||
| return books | ||
| ``` | ||
|
|
||
| Just like described in [defining query params using schema](./query-params.md#using-schema), Django Ninja converts the fields defined in `OrderingSchema` into query parameters. In this case, the field is only one: `order_by`. This field will accept multiple string values. | ||
|
|
||
| You can use a shorthand one-liner `.sort()` to apply the ordering to your queryset: | ||
|
|
||
| ```python hl_lines="4" | ||
| @api.get("/books") | ||
| def list_books(request, ordering: OrderingSchema = Query(...)): | ||
| books = Book.objects.all() | ||
| books = ordering.sort(books) | ||
| return books | ||
| ``` | ||
|
|
||
| Under the hood, `OrderingSchema` expose a query parameter `order_by` that can be used to order the queryset. The `order_by` parameter expects a list of string, representing the list of field names that will be passed to the `queryset.order_by(*args)` call. This values can be optionally prefixed by a minus sign (`-`) to indicate descending order, following the same standard from Django ORM. | ||
|
|
||
| If you want to use a different query parameter name, you can configure it in `Meta.ordering_query_param`: | ||
|
|
||
| ```python | ||
| class BookOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| ordering_query_param = "ordering" | ||
| ``` | ||
|
|
||
| With this configuration, Django Ninja will read `?ordering=...` from the query string and show `ordering` in the generated OpenAPI documentation. | ||
|
|
||
| ## Restricting & Mapping Fields | ||
|
|
||
| By default, `OrderingSchema` will allow to pass any field name to order the queryset. If you want to restrict the fields that can be used to order the queryset, you can use the `allowed_fields` field in the `OrderingSchema.Meta` class definition: | ||
|
|
||
| ```python hl_lines="3" | ||
| class BookOrderingSchema(OrderingSchema): | ||
| class Meta: | ||
| allowed_fields = ['name', 'created_at'] # Leaving out `author` field | ||
| ``` | ||
|
|
||
| This class definition will restrict the fields that can be used to order the queryset to only `name` and `created_at` fields. If the user tries to pass any other field, a `ValidationError` will be raised. | ||
|
|
||
| You can also use a dictionary to map the allowed field names to different field names in the queryset: | ||
|
|
||
| ```python hl_lines="3" | ||
| class BookOrderingSchema(OrderingSchema): | ||
| class Meta: | ||
| allowed_fields = { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks good, this offers some great flexibility. I think the duck-typing approach here to let this be either a dict or list is a good call and probably preferred to the alternate having two more strongly typed configs that could conflict (e.g. |
||
| 'name': 'real_name_model_field', | ||
| 'created': 'another__real__created_at', | ||
| } | ||
| ``` | ||
|
|
||
| ## Default Ordering | ||
|
|
||
| If you want to provide a default ordering to your queryset, you can assign a default value in the `order_by` field in the `OrderingSchema` class definition: | ||
|
|
||
| ```python hl_lines="2" | ||
| class BookOrderingSchema(OrderingSchema): | ||
| order_by: List[str] = ['name'] | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| from typing import Any, Generic, List, TypeVar | ||
|
|
||
| from django.db.models import QuerySet | ||
| from pydantic import ConfigDict, Field, field_validator | ||
| from pydantic.fields import FieldInfo | ||
|
|
||
| from .schema import Schema | ||
|
|
||
| QS = TypeVar("QS", bound=QuerySet) | ||
|
|
||
|
|
||
| class OrderingBaseSchema(Schema, Generic[QS]): | ||
| model_config = ConfigDict(from_attributes=True) | ||
|
|
||
| order_by: List[str] = Field(default_factory=list) | ||
|
|
||
| class Meta: | ||
| allowed_fields = "__all__" | ||
| ordering_query_param = "order_by" | ||
|
|
||
| @classmethod | ||
| def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: | ||
| super().__pydantic_init_subclass__(**kwargs) | ||
|
|
||
| ordering_query_param: str = getattr( | ||
| cls.Meta, "ordering_query_param", "order_by" | ||
| ) | ||
| order_by_field: FieldInfo = cls.model_fields["order_by"] | ||
|
|
||
| if ordering_query_param == "order_by": | ||
| order_by_field.alias = None | ||
| order_by_field.validation_alias = None | ||
| order_by_field.serialization_alias = None | ||
| else: | ||
| order_by_field.alias = ordering_query_param | ||
| order_by_field.validation_alias = ordering_query_param | ||
| order_by_field.serialization_alias = ordering_query_param | ||
|
|
||
| cls.model_rebuild(force=True) | ||
|
|
||
| @field_validator("order_by") | ||
| @classmethod | ||
| def validate_order_by_field(cls, value: List[str]) -> List[str]: | ||
| allowed_fields = cls.Meta.allowed_fields | ||
| if value and allowed_fields != "__all__": | ||
| allowed_fields_set = set(allowed_fields) | ||
| for order_field in value: | ||
| field_name = order_field.lstrip("-") | ||
| if field_name not in allowed_fields_set: | ||
| raise ValueError(f"Ordering by {field_name} is not allowed") | ||
|
|
||
| return value | ||
|
|
||
| @property | ||
| def parsed_order_by(self) -> List[str]: | ||
| parsed_order_by: List[str] = [] | ||
| if isinstance(self.Meta.allowed_fields, dict): | ||
| for field in self.order_by: | ||
| is_decreasing = field.startswith("-") | ||
| field_name = field.lstrip("-") | ||
| new_field_name = self.Meta.allowed_fields.get(field_name) | ||
| parsed_order_by.append( | ||
| f"-{new_field_name}" if is_decreasing else new_field_name | ||
| ) | ||
| return parsed_order_by | ||
| return self.order_by | ||
|
|
||
| def sort(self, queryset: QS) -> QS: | ||
| raise NotImplementedError | ||
|
|
||
|
|
||
| class OrderingSchema(OrderingBaseSchema): | ||
| def sort(self, queryset: QS) -> QS: | ||
| ordering_fields = self.parsed_order_by | ||
| if not ordering_fields: | ||
| return queryset | ||
| return queryset.order_by(*ordering_fields) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| import pytest | ||
| from django.db.models import QuerySet | ||
|
|
||
| from ninja import NinjaAPI, OrderingSchema, Query | ||
| from ninja.ordering_schema import OrderingBaseSchema | ||
| from ninja.testing import TestClient | ||
|
|
||
|
|
||
| class FakeQS(QuerySet): | ||
| def __init__(self, **kwargs): | ||
| super().__init__(**kwargs) | ||
| self.is_ordered = False | ||
|
|
||
| def order_by(self, *args, **kwargs): | ||
| self.is_ordered = True | ||
| self.order_by_args = args | ||
| self.order_by_kwargs = kwargs | ||
| return self | ||
|
|
||
|
|
||
| def test_validate_order_by_field__should_pass_when_all_field_allowed(): | ||
| test_field = "test_field" | ||
|
|
||
| class DummyOrderingSchema(OrderingSchema): | ||
| pass | ||
|
|
||
| order_by_value = [test_field] | ||
| validation_result = DummyOrderingSchema.validate_order_by_field(order_by_value) | ||
| assert validation_result == order_by_value | ||
|
|
||
|
|
||
| def test_validate_order_by_field__should_pass_when_value_in_allowed_fields_and_asc(): | ||
| test_field = "test_field" | ||
|
|
||
| class DummyOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| allowed_fields = [test_field] | ||
|
|
||
| order_by_value = [test_field] | ||
| validation_result = DummyOrderingSchema.validate_order_by_field(order_by_value) | ||
| assert validation_result == order_by_value | ||
|
|
||
|
|
||
| def test_validate_order_by_field__should_pass_when_value_in_allowed_fields_and_desc(): | ||
| test_field = "test_field" | ||
|
|
||
| class DummyOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| allowed_fields = [test_field] | ||
|
|
||
| order_by_value = [f"-{test_field}"] | ||
| validation_result = DummyOrderingSchema.validate_order_by_field(order_by_value) | ||
| assert validation_result == order_by_value | ||
|
|
||
|
|
||
| def test_validate_order_by_field__should_raise_validation_error_when_value_asc_not_in_allowed_fields(): | ||
| test_field = "allowed_field" | ||
|
|
||
| class DummyOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| allowed_fields = [test_field] | ||
|
|
||
| order_by_value = ["not_allowed_field"] | ||
| with pytest.raises(ValueError): | ||
| DummyOrderingSchema.validate_order_by_field(order_by_value) | ||
|
|
||
|
|
||
| def test_validate_order_by_field__should_raise_validation_error_when_value_desc_not_in_allowed_fields(): | ||
| test_field = "allowed_field" | ||
|
|
||
| class DummyOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| allowed_fields = [test_field] | ||
|
|
||
| order_by_value = ["-not_allowed_field"] | ||
| with pytest.raises(ValueError): | ||
| DummyOrderingSchema.validate_order_by_field(order_by_value) | ||
|
|
||
|
|
||
| def test_parsed_order_by__should_return_mapped_fields_when_allowed_fields_is_dict(): | ||
| class DummyOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| allowed_fields = { | ||
| "field1": "mapped_field1", | ||
| "field2": "mapped_field2", | ||
| } | ||
|
|
||
| ordering_schema = DummyOrderingSchema(order_by=["field1", "-field2"]) | ||
| assert ordering_schema.parsed_order_by == ["mapped_field1", "-mapped_field2"] | ||
|
|
||
|
|
||
| def test_parsed_order_by__should_return_original_fields_when_allowed_fields_is_not_dict(): | ||
| class DummyOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| allowed_fields = ["field1", "field2"] | ||
|
|
||
| ordering_schema = DummyOrderingSchema(order_by=["field1", "-field2"]) | ||
| assert ordering_schema.parsed_order_by == ["field1", "-field2"] | ||
|
|
||
|
|
||
| def test_sort__should_return_queryset_when_no_order_by(): | ||
| ordering_schema = OrderingSchema(order_by=[]) | ||
| queryset = FakeQS() | ||
| sorted_queryset = ordering_schema.sort(queryset) | ||
| assert sorted_queryset is queryset | ||
|
|
||
|
|
||
| def test_sort__should_call_order_by_on_queryset_with_expected_args(): | ||
| order_by_value = ["test_field_1", "-test_field_2"] | ||
| ordering_schema = OrderingSchema(order_by=order_by_value) | ||
|
|
||
| queryset = FakeQS() | ||
| queryset = ordering_schema.sort(queryset) | ||
| assert queryset.is_ordered | ||
| assert queryset.order_by_args == tuple(order_by_value) | ||
|
|
||
|
|
||
| def test_sort__should_raise_not_implemented_error(): | ||
| class DummyOrderingSchema(OrderingBaseSchema): | ||
| pass | ||
|
|
||
| with pytest.raises(NotImplementedError): | ||
| DummyOrderingSchema().sort(FakeQS()) | ||
|
|
||
|
|
||
| def test_sort__should_use_parsed_order_by(): | ||
| class DummyOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| allowed_fields = { | ||
| "field1": "mapped_field1", | ||
| "field2": "mapped_field2", | ||
| } | ||
|
|
||
| order_by_value = ["field1", "-field2"] | ||
| ordering_schema = DummyOrderingSchema(order_by=order_by_value) | ||
|
|
||
| queryset = FakeQS() | ||
| queryset = ordering_schema.sort(queryset) | ||
| assert queryset.is_ordered | ||
| assert queryset.order_by_args == ("mapped_field1", "-mapped_field2") | ||
|
|
||
|
|
||
| def test_ordering_query_param__should_parse_custom_query_param(): | ||
| api = NinjaAPI(urls_namespace="test_ordering_query_param") | ||
|
|
||
| class CustomOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| ordering_query_param = "ordering" | ||
|
|
||
| @api.get("/items") | ||
| def list_items(request, ordering: CustomOrderingSchema = Query(...)): | ||
| return ordering.order_by | ||
|
|
||
| client = TestClient(api) | ||
| response = client.get("/items?ordering=name&ordering=-created_at") | ||
|
|
||
| assert response.status_code == 200 | ||
| assert response.json() == ["name", "-created_at"] | ||
|
|
||
|
|
||
| def test_ordering_query_param__should_use_custom_name_in_openapi(): | ||
| api = NinjaAPI(urls_namespace="test_ordering_query_param_openapi") | ||
|
|
||
| class CustomOrderingSchema(OrderingSchema): | ||
| class Meta(OrderingSchema.Meta): | ||
| ordering_query_param = "ordering" | ||
|
|
||
| @api.get("/items") | ||
| def list_items(request, ordering: CustomOrderingSchema = Query(...)): | ||
| return ordering.order_by | ||
|
|
||
| parameters = api.get_openapi_schema(path_prefix="")["paths"]["/items"]["get"][ | ||
| "parameters" | ||
| ] | ||
| parameter_names = {parameter["name"] for parameter in parameters} | ||
|
|
||
| assert "ordering" in parameter_names | ||
| assert "order_by" not in parameter_names |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice!