Skip to content
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

Adding OrderingSchema for ordering QuerySets #1291

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
51 changes: 51 additions & 0 deletions docs/docs/guides/input/ordering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 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.

## Restricting 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.Config` class definition:

```python hl_lines="3"
class BookOrderingSchema(OrderingSchema):
class Config(OrderingSchema.Config):
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.

## 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']
```
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ nav:
- guides/input/file-params.md
- guides/input/request-parsers.md
- guides/input/filtering.md
- guides/input/ordering.md
- Handling responses:
- Defining a Schema: guides/response/index.md
- guides/response/temporal_response.md
Expand Down
2 changes: 2 additions & 0 deletions ninja/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from ninja.filter_schema import FilterSchema
from ninja.main import NinjaAPI
from ninja.openapi.docs import Redoc, Swagger
from ninja.ordering_schema import OrderingSchema
from ninja.orm import ModelSchema
from ninja.params import (
Body,
Expand Down Expand Up @@ -54,6 +55,7 @@
"Schema",
"ModelSchema",
"FilterSchema",
"OrderingSchema",
"Swagger",
"Redoc",
"PatchDict",
Expand Down
36 changes: 36 additions & 0 deletions ninja/ordering_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Any, List, TypeVar

from django.db.models import QuerySet
from pydantic import field_validator

from .schema import Schema

QS = TypeVar("QS", bound=QuerySet)


class OrderingBaseSchema(Schema):
order_by: List[str] = []

class Config(Schema.Config):
allowed_fields = "__all__"

@field_validator("order_by")
@classmethod
def validate_order_by_field(cls, value: List[str]) -> List[str]:
allowed_fields = cls.Config.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

def sort(self, elements: Any) -> Any:
raise NotImplementedError


class OrderingSchema(OrderingBaseSchema):
def sort(self, queryset: QS) -> QS:
return queryset.order_by(*self.order_by)
94 changes: 94 additions & 0 deletions tests/test_ordering_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import pytest
from django.db.models import QuerySet

from ninja import OrderingSchema
from ninja.ordering_schema import OrderingBaseSchema


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 Config(OrderingSchema.Config):
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 Config(OrderingSchema.Config):
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 Config(OrderingSchema.Config):
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 Config(OrderingSchema.Config):
allowed_fields = [test_field]

order_by_value = ["-not_allowed_field"]
with pytest.raises(ValueError):
DummyOrderingSchema.validate_order_by_field(order_by_value)


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())
Loading