diff --git a/docs/docs/guides/input/ordering.md b/docs/docs/guides/input/ordering.md new file mode 100644 index 000000000..7e9dac696 --- /dev/null +++ b/docs/docs/guides/input/ordering.md @@ -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'] +``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fc06094d3..126407135 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/ninja/__init__.py b/ninja/__init__.py index a48ef3e35..338445f12 100644 --- a/ninja/__init__.py +++ b/ninja/__init__.py @@ -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, @@ -54,6 +55,7 @@ "Schema", "ModelSchema", "FilterSchema", + "OrderingSchema", "Swagger", "Redoc", "PatchDict", diff --git a/ninja/ordering_schema.py b/ninja/ordering_schema.py new file mode 100644 index 000000000..f8bbd402d --- /dev/null +++ b/ninja/ordering_schema.py @@ -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) diff --git a/tests/test_ordering_schema.py b/tests/test_ordering_schema.py new file mode 100644 index 000000000..d8781381b --- /dev/null +++ b/tests/test_ordering_schema.py @@ -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())