Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f907b25
Adding OrderBaseSchema as an initial placeholder
aryan-curiel Sep 2, 2024
ab7ed55
Added field validation to the OrderingBaseSchema
aryan-curiel Sep 2, 2024
414225e
Added Django OrderingSchema
aryan-curiel Sep 2, 2024
3b75169
Adding OrderingSchema to the base module
aryan-curiel Sep 2, 2024
ae370c8
Removed unneded Genericity on OrderingSchema
aryan-curiel Sep 2, 2024
726cc46
Fixed wrong exception on value validation
aryan-curiel Sep 2, 2024
e3f3ed8
Add ordering schema tests
aryan-curiel Sep 2, 2024
8bd6e9e
ADded `ordering` to documentation
aryan-curiel Sep 2, 2024
9a836db
Fixed test coverage
aryan-curiel Sep 2, 2024
dbbdb27
Merge branch 'master' into feature/add-ordering-schema
aryan-curiel Jul 21, 2025
1156499
Reeturning queryset when empty order_by
aryan-curiel Jul 21, 2025
ae21e04
Migrate OrderingBaseSchema from Config subclass to Meta subclass
aryan-curiel Aug 13, 2025
747e966
Fixed Meta class inheritance in OrderingSchema
aryan-curiel Aug 13, 2025
d5855fe
Merge branch 'master' into feature/add-ordering-schema
aryan-curiel Aug 13, 2025
228c0c7
Documentation updated reflecting Config -> Meta changes
aryan-curiel Aug 13, 2025
d61b663
Merge branch 'master' into feature/add-ordering-schema
aryan-curiel Nov 5, 2025
5e4ebd5
Remove unused import in __all__
aryan-curiel Nov 5, 2025
43ed121
Merge branch 'master' into feature/add-ordering-schema
aryan-curiel Mar 4, 2026
91011f8
Allow field mapping
aryan-curiel Mar 4, 2026
157fdd3
Allowing to set up custom ordering field
aryan-curiel Mar 4, 2026
eef8e8b
Update documentation
aryan-curiel Mar 4, 2026
9144074
Fix type check
aryan-curiel Mar 4, 2026
8406127
Fix coverage
aryan-curiel Mar 4, 2026
240848b
Fix type checks
aryan-curiel Mar 4, 2026
2d76aa9
Fix type checks
aryan-curiel Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/docs/guides/input/ordering.md
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"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice!

```

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 = {

Choose a reason for hiding this comment

The 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. allowed_fields, allowed_fields_dict)

'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']
```
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,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 FilterConfigDict, FilterLookup, 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 @@ -56,6 +57,7 @@
"Schema",
"ModelSchema",
"FilterSchema",
"OrderingSchema",
"FilterLookup",
"FilterConfigDict",
"Swagger",
Expand Down
77 changes: 77 additions & 0 deletions ninja/ordering_schema.py
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)
178 changes: 178 additions & 0 deletions tests/test_ordering_schema.py
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