django-ninja icon indicating copy to clipboard operation
django-ninja copied to clipboard

OR filter for values of the same field

Open stvdrsch opened this issue 1 year ago • 4 comments

What I'm trying to achieve is changing the URL to a more comprehensive format when doing an OR selection for several values of the same field.

This is my custom filter

from ninja import FilterSchema, Field
from models import (
    TradeIn,
)
from django.db.models import Q
import enum

class TradeInFilterSchema(FilterSchema):
    state: list[
        enum.Enum(
            "MyEnum", [(name, name) for name in TradeIn.TradeInState._member_names_]
        )
    ] = Field([])
    # member names

    def filter_state(self, value: list[str]) -> Q:
        """Custom filter to it translates the values to"""
        filter_on_states = []
        for state in value:
            filter_on_states.append(TradeIn.TradeInState.__members__[state.value].value)
        return Q(
            state__in=filter_on_states  # as we use the member value in the filter do this to filter against the value in the DB
        )

applying that filter on a /trade-ins/ endpoint results in this URL:

'http://localhost:8008/api/trade-ins?state=INITIAL&state=CONFIRMED&limit=100&offset=0' Although this perfectly does what I want it to, one could misinterpret the URL as filtering on INITIAL AND CONFIRMED

Is there a possibility, while using django-ninja, to transform the url to something where it's clearer that I'm doing an OR statement here? Something like

'http://localhost:8008/api/trade-ins?state=INITIAL,CONFIRMED&limit=100&offset=0'?

stvdrsch avatar Jan 09 '24 13:01 stvdrsch

Hi @stvdrsch

You can achieve this by making custom type (that access comma separated string and turns to list) - docs are here

example:

from typing_extensions import Annotated
from pydantic import BeforeValidator, BaseModel


CommaToStrList = Annotated[
    list[str],
    BeforeValidator(lambda x: x.split(',')),
]


class Data(BaseModel):
    state: CommaToStrList


data = Data(state='a,b,c')
print(data)

output

state=['a', 'b', 'c']

vitalik avatar Jan 09 '24 14:01 vitalik

@vitalik - thanks for this. Is that code supposed to fit within the FilterSchema?

cameroon16 avatar Jan 13 '24 21:01 cameroon16

@cameroon16, yes. Since ninja.FilterSchema inherits from ninja.Schema which in turn inherits from pydantic.BaseModel, you can leverage the power of Pydantic:

CommaToStrList = Annotated[
    list[str],
    BeforeValidator(lambda x: x.split(',')),
]


class TradeInFilterSchema(FilterSchema):
    state: CommaToStrList | None = Field(None, q="state__in")


@api.get("/tradein")
def test(request, filters: Query[TradeInFilterSchema]):
    print(filters.state)
    return filters.filter(TradeIn.objects.all())
    # ...

You no longer need a custom filter_state method and can just use q="state__in" kwarg on the filter field definition since you already have a proper list that can be passed to Django ORM:

GET /tradein will print None, TradeIns will not be filtered (FilterSchema ignores Nones by default);

GET /tradein?state=FOO will print ["FOO"], TradeIns will be filtered by state__in=["FOO"];

GET /tradein?state=FOO,BAR will print ["FOO", "BAR"], TradeIns will be filtered by `state__in=["FOO", "BAR"].


Note: since CommaToStrList annotation is using a BeforeValidator, it's dealing with raw input. You might want to cover some corner cases, such as:

  • stripping away possible whitespaces and empty values (e.g. "FOO,BAR,".split(",") == ["FOO", "BAR", ""])
  • validating the list against a predefined list of possible states.

You might upgrade ComaToStrList to something like ListOfStates:

def raw_str_to_list_of_states(value: str) -> list[TradeIn.TradeInState]:
    # split value
    # trim whitespaces, discard empty items
    # validate each individual item against TradeInState enum
    # return list of TradeInEnum items

ListOfStates = Annotated[list[TradeIn.TradeInState], BeforeValidator(raw_str_to_list_of_states)]

l1b3r avatar Jan 19 '24 13:01 l1b3r

Update 1 (good news)

Thanks to Pydantic, the validation can be as simple as following (verified in my own projects):

from typing import TypeVar, Annotated
from pydantic import BeforeValidator
from ninja import FilterSchema

T = TypeVar("T")

# Splits comma-separated string into a list of items.
# Strips whitespaces and trims leading/trailing commas
# This annotation can be reused many times for different enums or plain strings
CommaList = Annotated[list[T], BeforeValidator(lambda x: [v for v in (v.strip() for v in x.split(",")) if v])]

class TradeInFilterSchema(FilterSchema):
    state: CommaList[TradeIn.TradeInState] | None = Field(None, q="state__in")

The TradeInFilterSchema.state will ultimately be transformed (and validated to conform) to a list of TradeIn.TradeInState enum values which can be fed straight to Django ORM for filtering (thanks to q="state__in" lookup parameter in Field).


Update 2 (not so good news)

By default, Swagger UI (OpenAPI 3.1 to be more precise) uses a combination of style: form + explode: true default attributes when serializing query params of an API method. Essentially that means that no matter the code above, Swagger UI will still attempt to send the state options as state=STATE1&state=STATE2, not state=STATE1,STATE2.

To fix that, one must force a style: form + explode: **false** combination for the comma-separated parameters. I found no easy way to do this because Ninja seems to not configure these parameter attributes. One hacky workaround to have this fixed is to override NinjaAPI.get_openapi_schema and inject those attributes in the schema where needed:

class MyNinjaAPI(NinjaAPI):
        def get_openapi_schema(self, *, path_prefix: Optional[str] = None, path_params: Optional[DictStrAny] = None,
    ) -> OpenAPISchema:
            schema = super().get_openapi_schema(path_prefix=path_prefix, path_params=path_params)
            # traverse the schema here and insert "explode: false" where needed
            return schema

l1b3r avatar Feb 21 '24 00:02 l1b3r