Repository: vitalik/django-ninja
Pull request #673 (2023-01-30 23:19:47)

Somewhat advanced filtering with FilterSchema
The documentation section [explains](https://django-ninja.rest-framework.com/guides/input/query-params/#using-schema) how to add query parameters to an API handler encapsulated into a separate schema:
```python
class Filters(Schema):
    limit: int = 100
    offset: int = None
    query: str = None
    category__in: List[str] = Field(None, alias="categories")


@api.get("/filter")
def events(request, filters: Filters = Query(...)):
    return {"filters": filters.dict()}
```
 That is helpful already as it:
1. Takes care of documenting and validating the fields in the schema;
2. Provides an explicit separation of concerns to the parameters of the method.

However, it seems that when it comes to actually applying those filters in the API handler, the developer is left on their own and will probably have to do something like this all the time:
```python
@api.get("/filter")
def events(request, filters: Filters = Query(...)):
    events = Event.objects.all()
    if filters.query:
        events = events.filter(name__icontains=filters.query)
    if filters.category__in:
        events = events.filter(category__in=filters.category__in)
    # the more filters the more ifs
    return events
```

While this approach is obviously not very DRY and readable, it also exposes unexperienced Django developers to a risk of getting unexpected filtering results when multiple `.filter` clauses are used. There is a slight [difference](https://stackoverflow.com/questions/8164675/chaining-multiple-filter-in-django-is-this-a-bug) in how Django translates ORM queries with multiple `.filter`s into SQL queries.

In an attempt to bridge the gap between the API layer and the DB execution layer, this PR is introducing a `FilterSchema` class. It inherits from Ninja's (Pydantic's) `Schema` and provides extra functionality that would convert filter values into ready to use Django Q expressions that can be fed straight into the queryset.

Below I will include the documentation for the feature from this PR as is. Hopefully, it explains it sufficiently enough.

Please let me know what you think about the proposal and if it has a chance to make it.

- - -

# Filtering

If you want to allow the user to filter your querysets by a number of different attributes, it makes sense
to encapsulate your filters into a `FilterSchema` class. `FilterSchema` is a regular `Schema`, so it's using all the
necessary features of Pydantic, but it also adds some bells and whistles that ease the translation of the user-facing filtering
parameters into database queries. 

Start off with defining a subclass of `FilterSchema`:

```python hl_lines="6 7 8"
from ninja import FilterSchema, Field
from typing import Optional


class BookFilterSchema(FilterSchema):
    name: Optional[str] = Field(q='name__icontains')
    author: Optional[str] = Field(q='author__name__icontains')
    created_after: Optional[datetime] = Field(q='created__gte')
```

Pay attention to the field definition. `FilterSchema` requires that you provide a kwarg `q`, which should contain
a keyword argument name which will then under the hood be used to translate the filter values into a [Q](https://docs.djangoproject.com/en/3.1/topics/db/queries/#complex-lookups-with-q-objects) expression used for filtering the queryset.


Next, use this schema in conjunction with `Query` in your API handler:
```python hl_lines="2"
@api.get("/books")
def list_books(request, filters: BookFilterSchema = Query(...)):
    books = Book.objects.all()
    books = filters.filter(books)
    return books
```

Just like described in [defining query params using schema](./query-params.md#using-schema), Django Ninja converts the fields
defined in `BookFilterSchema` into query parameters.

You can use a shorthand one-liner `.filter()` to apply those filters to your queryset:
```python hl_lines="4"
@api.get("/books")
def list_books(request, filters: BookFilterSchema = Query(...)):
    books = Book.objects.all()
    books = filters.filter(books)
    return books
```

Alternatively, you can get the prepared `Q`-expression and perform the filtering yourself.
That can be useful, when you have some additional queryset filtering on top of what you expose to the API:
```python hl_lines="4 5 7 8"
@api.get("/books")
def list_books(request, filters: BookFilterSchema = Query(...)):

    # Never serve books from inactive publishers and authors
    q = Q(author__is_active=True) | Q(publisher__is_active=True)
    
    # But allow filtering the rest of the books
    q &= filters.get_filter_expression()
    return Book.objects.filter(q)
```

By default, the filters will behave the following way:

* `None` values will be ignored and not filtered against;
* Every non-`None` field will be converted into a `Q`-expression based on the `Field` definition of each field;
* All `Q`-expressions will be merged into one using `AND` logical operator;
* The resulting `Q`-expression is used to filter the queryset and return you a qeryset with a `.filter` clause applied.


## Filtering by Nones
You can make the `FilterSchema` treat `None` as a valid value that should be filtered against.

This can be done on a field level with a `ignore_none` kwarg:
```python hl_lines="3"
class BookFilterSchema(FilterSchema):
    name: Optional[str] = Field(q='name__icontains')
    tag: Optional[str] = Field(q='tag', ignore_none=False)
```

This way when no other value for `"tag"` is provided by the user, the filtering will always include a condition `tag=None`.

You can also specify this settings for all fields at the same time in the Config:
```python hl_lines="6"
class BookFilterSchema(FilterSchema):
    name: Optional[str] = Field(q='name__icontains')
    tag: Optional[str] = Field(q='tag', ignore_none=False)
    
    class Config:
        ignore_none = False
```


## Combining expressions
By default filters are joined together using `AND` operator. This can be changed in the schema Config:

```python
class BookFilterSchema(FilterSchema):
    name: Optional[str] = Field(q='name__icontains')
    author: Optional[str] = Field(q='author__name__icontains')
    created_after: Optional[datetime] = Field(q='created__gte')
    
    class Config:
        expression_connector = 'OR'     # can be 'AND', 'OR', 'XOR'
```

With such filtering configuration the endpoint...
```python
http://localhost:8000/api/books?name=harry&author=poe
```
...will return Harry Potter series as well as books from Edgar Allan Poe.


## Custom expressions
Sometimes you might want to have complex filtering scenarios that cannot be handled by individual Field annotations.
For such cases you can implement your own custom logic in a `custom_expression` method:

```python
class BookFilterSchema(FilterSchema):
    name: Optional[str]         # No need to supply "q" kwarg
    popular: Optional[bool]     # when custom expression is used

    def custom_expression(self) -> Q:
        q = Q()
        if self.name:
            q &= Q(name__icontains=self.name)
        if self.popular:
            q &= (
                Q(view_count__gt=1000) |
                Q(downloads__gt=100) |
                Q(tag='popular')
            )
        return q
```


Patch:
diff --git a/docs/docs/guides/input/filtering.md b/docs/docs/guides/input/filtering.md
new file mode 100644
index 0000000..08d1fc1
--- /dev/null
+++ b/docs/docs/guides/input/filtering.md
@@ -0,0 +1,185 @@
+# Filtering
+
+If you want to allow the user to filter your querysets by a number of different attributes, it makes sense
+to encapsulate your filters into a `FilterSchema` class. `FilterSchema` is a regular `Schema`, so it's using all the
+necessary features of Pydantic, but it also adds some bells and whistles that ease the translation of the user-facing filtering
+parameters into database queries. 
+
+Start off with defining a subclass of `FilterSchema`:
+
+```python hl_lines="6 7 8"
+from ninja import FilterSchema, Field
+from typing import Optional
+
+
+class BookFilterSchema(FilterSchema):
+    name: Optional[str]
+    author: Optional[str]
+    created_after: Optional[datetime]
+```
+
+
+Next, use this schema in conjunction with `Query` in your API handler:
+```python hl_lines="2"
+@api.get("/books")
+def list_books(request, filters: BookFilterSchema = Query(...)):
+    books = Book.objects.all()
+    books = filters.filter(books)
+    return books
+```
+
+Just like described in [defining query params using schema](./query-params.md#using-schema), Django Ninja converts the fields
+defined in `BookFilterSchema` into query parameters.
+
+You can use a shorthand one-liner `.filter()` to apply those filters to your queryset:
+```python hl_lines="4"
+@api.get("/books")
+def list_books(request, filters: BookFilterSchema = Query(...)):
+    books = Book.objects.all()
+    books = filters.filter(books)
+    return books
+```
+
+Under the hood, `FilterSchema` converts its fields into [Q expressions](https://docs.djangoproject.com/en/3.1/topics/db/queries/#complex-lookups-with-q-objects) which it then combines and uses to filter your queryset.
+
+
+Alternatively to using the `.filter` method, you can get the prepared `Q`-expression and perform the filtering yourself.
+That can be useful, when you have some additional queryset filtering on top of what you expose to the user through the API:
+```python hl_lines="5 8"
+@api.get("/books")
+def list_books(request, filters: BookFilterSchema = Query(...)):
+
+    # Never serve books from inactive publishers and authors
+    q = Q(author__is_active=True) | Q(publisher__is_active=True)
+    
+    # But allow filtering the rest of the books
+    q &= filters.get_filter_expression()
+    return Book.objects.filter(q)
+```
+
+By default, the filters will behave the following way:
+
+* `None` values will be ignored and not filtered against;
+* Every non-`None` field will be converted into a `Q`-expression based on the `Field` definition of each field;
+* All `Q`-expressions will be merged into one using `AND` logical operator;
+* The resulting `Q`-expression is used to filter the queryset and return you a qeryset with a `.filter` clause applied.
+
+## Customizing Fields
+By default, `FilterSet` will use the field names to generate Q expressions:
+```python
+class BookFilterSchema(FilterSchema):
+    name: Optional[str]
+```
+The `name` field will be converted into `Q(name=...)` expression.
+
+When your database lookups are more complicated than that, you can explicitly specify them in the field definition using a `"q"` kwarg:
+```python hl_lines="2"
+class BookFilterSchema(FilterSchema):
+    name: Optional[str] = Field(q='name__icontains') 
+```
+You can even specify multiple lookup keyword argument names as a list:
+```python hl_lines="2 3 4"
+class BookFilterSchema(FilterSchema):
+    search: Optional[str] = Field(q=['name__icontains',
+                                     'author__name__icontains',
+                                     'publisher__name__icontains']) 
+```
+By default, field-level expressions are combined using `"OR"` connector, so with the above setup, a query parameter `?search=foobar` will search for books that have "foobar" in either of their name, author or publisher.
+
+
+## Combining expressions
+By default,
+
+* Field-level expressions are joined together using `OR` operator.
+* The fields themselves are joined together using `AND` operator.
+
+So, with the following `FilterSchema`...
+```python
+class BookFilterSchema(FilterSchema):
+    search: Optional[str] = Field(q=['name__icontains', 'author__name__icontains'])
+    popular: Optional[bool]
+```
+...and the following query parameters from the user
+```
+http://localhost:8000/api/books?search=harry&popular=true
+```
+the `FilterSchema` instance will look for popular books that have `harry` in the book's _or_ author's name. 
+
+
+You can customize this behavior using an `expression_connector` argument in field-level and class-level definition:
+```python hl_lines="3 7"
+class BookFilterSchema(FilterSchema):
+    active: Optional[bool] = Field(q=['is_active', 'publisher__is_active'],
+                                   expression_connector='AND')
+    name: Optional[str] = Field(q='name__icontains')
+    
+    class Config:
+        expression_connector = 'OR'
+```
+
+An expression connector can take the values of `"OR"`, `"AND"` and `"XOR"`, but the latter is only [supported](https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor) in Django starting with 4.1.
+
+Now, a request with these query parameters 
+```
+http://localhost:8000/api/books?name=harry&active=true
+```
+...shall search for books that have `harry` in their name _or_ are active themselves _and_ are published by active publishers.
+
+
+## Filtering by Nones
+You can make the `FilterSchema` treat `None` as a valid value that should be filtered against.
+
+This can be done on a field level with a `ignore_none` kwarg:
+```python hl_lines="3"
+class BookFilterSchema(FilterSchema):
+    name: Optional[str] = Field(q='name__icontains')
+    tag: Optional[str] = Field(q='tag', ignore_none=False)
+```
+
+This way when no other value for `"tag"` is provided by the user, the filtering will always include a condition `tag=None`.
+
+You can also specify this settings for all fields at the same time in the Config:
+```python hl_lines="6"
+class BookFilterSchema(FilterSchema):
+    name: Optional[str] = Field(q='name__icontains')
+    tag: Optional[str] = Field(q='tag', ignore_none=False)
+    
+    class Config:
+        ignore_none = False
+```
+
+
+## Custom expressions
+Sometimes you might want to have complex filtering scenarios that cannot be handled by individual Field annotations.
+For such cases you can implement your field filtering logic as a custom method. Simply define a method called `filter_<fieldname>` which takes a filter value and returns a Q expression:
+
+```python hl_lines="5"
+class BookFilterSchema(FilterSchema):
+    tag: Optional[str]
+    popular: Optional[bool]
+    
+    def filter_popular(self, value: bool) -> Q:
+        return Q(view_count__gt=1000) | Q(download_count__gt=100) if value else Q()
+```
+Such field methods take precedence over what is specified in the `Field()` definition of the corresponding fields.
+
+If that is not enough, you can implement your own custom filtering logic for the entire `FilterSet` class in a `custom_expression` method:
+
+```python hl_lines="5"
+class BookFilterSchema(FilterSchema):
+    name: Optional[str]
+    popular: Optional[bool]
+
+    def custom_expression(self) -> Q:
+        q = Q()
+        if self.name:
+            q &= Q(name__icontains=self.name)
+        if self.popular:
+            q &= (
+                Q(view_count__gt=1000) |
+                Q(downloads__gt=100) |
+                Q(tag='popular')
+            )
+        return q
+```
+The `custom_expression` method takes precedence over any other definitions described earlier, including `filter_<fieldname>` methods.
\ No newline at end of file
diff --git a/docs/docs/guides/input/query-params.md b/docs/docs/guides/input/query-params.md
index 8871af7..d1397e4 100644
--- a/docs/docs/guides/input/query-params.md
+++ b/docs/docs/guides/input/query-params.md
@@ -105,3 +105,5 @@ You can also use Schema to encapsulate GET parameters:
 ```Python hl_lines="1 2  5 6 7 8"
 {!./src/tutorial/query/code010.py!}
 ```
+
+For more complex filtering scenarios please refer to [filtering](./filtering.md).
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 7097481..15fe21b 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -42,6 +42,7 @@ nav:
           - guides/input/form-params.md
           - guides/input/file-params.md
           - guides/input/request-parsers.md
+          - guides/input/filtering.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 0d1fed3..f581252 100644
--- a/ninja/__init__.py
+++ b/ninja/__init__.py
@@ -5,6 +5,7 @@ __version__ = "0.20.0"
 from pydantic import Field
 
 from ninja.files import UploadedFile
+from ninja.filter_schema import FilterSchema
 from ninja.main import NinjaAPI
 from ninja.orm import ModelSchema
 from ninja.params_functions import Body, Cookie, File, Form, Header, Path, Query
@@ -25,4 +26,5 @@ __all__ = [
     "Router",
     "Schema",
     "ModelSchema",
+    "FilterSchema",
 ]
diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py
new file mode 100644
index 0000000..e2fe673
--- /dev/null
+++ b/ninja/filter_schema.py
@@ -0,0 +1,95 @@
+from typing import TYPE_CHECKING, Any, ClassVar, Type, cast
+
+from django.core.exceptions import ImproperlyConfigured
+from django.db.models import Q, QuerySet
+from pydantic import BaseConfig
+from pydantic.fields import ModelField
+from typing_extensions import Literal
+
+from .schema import Schema
+
+DEFAULT_IGNORE_NONE = True
+DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR = "AND"
+DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR = "OR"
+
+# XOR is available only in Django 4.1+: https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor
+ExpressionConnector = Literal["AND", "OR", "XOR"]
+
+
+class FilterConfig(BaseConfig):
+    ignore_none: bool = DEFAULT_IGNORE_NONE
+    expression_connector: ExpressionConnector = cast(
+        ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
+    )
+
+
+class FilterSchema(Schema):
+    if TYPE_CHECKING:
+        __config__: ClassVar[Type[FilterConfig]] = FilterConfig  # pragma: no cover
+
+    Config = FilterConfig
+
+    def custom_expression(self) -> Q:
+        """
+        Implement this method to return a combination of filters that will be used
+        """
+        raise NotImplementedError
+
+    def get_filter_expression(self) -> Q:
+        """
+        Returns a Q expression based on the current filters
+        """
+        try:
+            return self.custom_expression()
+        except NotImplementedError:
+            return self._connect_fields()
+
+    def filter(self, queryset: QuerySet) -> QuerySet:
+        return queryset.filter(self.get_filter_expression())
+
+    def _resolve_field_expression(
+        self, field_name: str, field_value: Any, field: ModelField
+    ) -> Q:
+        func = getattr(self, f"filter_{field_name}", None)
+        if callable(func):
+            return func(field_value)  # type: ignore[no-any-return]
+
+        q_expression = field.field_info.extra.get("q", None)
+        if not q_expression:
+            return Q(**{field_name: field_value})
+        elif isinstance(q_expression, str):
+            return Q(**{q_expression: field_value})
+        elif isinstance(q_expression, list):
+            expression_connector = field.field_info.extra.get(
+                "expression_connector", DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR
+            )
+            q = Q()
+            for q_expression_part in q_expression:
+                q = q._combine(Q(**{q_expression_part: field_value}), expression_connector)  # type: ignore[attr-defined]
+            return q
+        else:
+            raise ImproperlyConfigured(
+                f"Field {field_name} of {self.__class__.__name__} defines an invalid value under 'q' kwarg.\n"
+                f"Define a 'q' kwarg as a string or a list of strings, each string corresponding to a database lookup you wish to filter against:\n"
+                f"  {field_name}: {field.annotation} = Field(..., q='<here>')\n"
+                f"or\n"
+                f"  {field_name}: {field.annotation} = Field(..., q=['lookup1', 'lookup2', ...])\n"
+                f"Alternatively, you can implement {self.__class__.__name__}.filter_{field_name} that must return a Q expression for that field"
+            )
+
+    def _connect_fields(self) -> Q:
+        q = Q()
+        for field_name, field in self.__fields__.items():
+            filter_value = getattr(self, field_name)
+            ignore_none = field.field_info.extra.get(
+                "ignore_none", self.__config__.ignore_none
+            )
+
+            # Resolve q for a field even if we skip it due to None value
+            # So that improperly configured fields are easier to detect
+            field_q = self._resolve_field_expression(field_name, filter_value, field)
+            if filter_value is None and ignore_none:
+                continue
+            q = q._combine(field_q, self.__config__.expression_connector)  # type: ignore[attr-defined]
+
+        return q


Test patch:
diff --git a/tests/test_filter_schema.py b/tests/test_filter_schema.py
new file mode 100644
index 0000000..43b42a3
--- /dev/null
+++ b/tests/test_filter_schema.py
@@ -0,0 +1,189 @@
+from typing import Optional
+
+import pytest
+from django.core.exceptions import ImproperlyConfigured
+from django.db.models import Q, QuerySet
+from pydantic import Field
+
+from ninja import FilterSchema
+
+
+class FakeQS(QuerySet):
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self.filtered = False
+
+    def filter(self, *args, **kwargs):
+        self.filtered = True
+        return self
+
+
+def test_simple_config():
+    class DummyFilterSchema(FilterSchema):
+        name: Optional[str]
+
+    filter_instance = DummyFilterSchema(name="foobar")
+    q = filter_instance.get_filter_expression()
+    assert q == Q(name="foobar")
+
+
+def test_improperly_configured():
+    class DummyFilterSchema(FilterSchema):
+        popular: Optional[str] = Field(q=Q(view_count__gt=1000))
+
+    filter_instance = DummyFilterSchema()
+    with pytest.raises(ImproperlyConfigured):
+        filter_instance.get_filter_expression()
+
+
+def test_empty_q_when_none_ignored():
+    class DummyFilterSchema(FilterSchema):
+        name: Optional[str] = Field(q="name__icontains")
+        tag: Optional[str] = Field(q="tag")
+
+    filter_instance = DummyFilterSchema()
+    q = filter_instance.get_filter_expression()
+    assert q == Q()
+
+
+def test_q_expressions2():
+    class DummyFilterSchema(FilterSchema):
+        name: Optional[str] = Field(q="name__icontains")
+        tag: Optional[str] = Field(q="tag")
+
+    filter_instance = DummyFilterSchema(name="John", tag=None)
+    q = filter_instance.get_filter_expression()
+    assert q == Q(name__icontains="John")
+
+
+def test_q_expressions3():
+    class DummyFilterSchema(FilterSchema):
+        name: Optional[str] = Field(q="name__icontains")
+        tag: Optional[str] = Field(q="tag")
+
+    filter_instance = DummyFilterSchema(name="John", tag="active")
+    q = filter_instance.get_filter_expression()
+    assert q == Q(name__icontains="John") & Q(tag="active")
+
+
+def test_q_is_a_list():
+    class DummyFilterSchema(FilterSchema):
+        name: Optional[str] = Field(q=["name__icontains", "user__username__icontains"])
+        tag: Optional[str] = Field(q="tag")
+
+    filter_instance = DummyFilterSchema(name="foo", tag="bar")
+    q = filter_instance.get_filter_expression()
+    assert q == (Q(name__icontains="foo") | Q(user__username__icontains="foo")) & Q(
+        tag="bar"
+    )
+
+
+def test_field_level_expression_connector():
+    class DummyFilterSchema(FilterSchema):
+        name: Optional[str] = Field(
+            q=["name__icontains", "user__username__icontains"],
+            expression_connector="AND",
+        )
+        tag: Optional[str] = Field(q="tag")
+
+    filter_instance = DummyFilterSchema(name="foo", tag="bar")
+    q = filter_instance.get_filter_expression()
+    assert q == Q(name__icontains="foo") & Q(user__username__icontains="foo") & Q(
+        tag="bar"
+    )
+
+
+def test_class_level_expression_connector():
+    class DummyFilterSchema(FilterSchema):
+        tag1: Optional[str] = Field(q="tag1")
+        tag2: Optional[str] = Field(q="tag2")
+
+        class Config:
+            expression_connector = "OR"
+
+    filter_instance = DummyFilterSchema(tag1="foo", tag2="bar")
+    q = filter_instance.get_filter_expression()
+    assert q == Q(tag1="foo") | Q(tag2="bar")
+
+
+def test_class_level_and_field_level_expression_connector():
+    class DummyFilterSchema(FilterSchema):
+        name: Optional[str] = Field(
+            q=["name__icontains", "user__username__icontains"],
+            expression_connector="AND",
+        )
+        tag: Optional[str] = Field(q="tag")
+
+        class Config:
+            expression_connector = "OR"
+
+    filter_instance = DummyFilterSchema(name="foo", tag="bar")
+    q = filter_instance.get_filter_expression()
+    assert q == Q(name__icontains="foo") & Q(user__username__icontains="foo") | Q(
+        tag="bar"
+    )
+
+
+def test_ignore_none():
+    class DummyFilterSchema(FilterSchema):
+        tag: Optional[str] = Field(q="tag", ignore_none=False)
+
+    filter_instance = DummyFilterSchema()
+    q = filter_instance.get_filter_expression()
+    assert q == Q(tag=None)
+
+
+def test_ignore_none_class_level():
+    class DummyFilterSchema(FilterSchema):
+        tag1: Optional[str] = Field(q="tag1")
+        tag2: Optional[str] = Field(q="tag2")
+
+        class Config:
+            ignore_none = False
+
+    filter_instance = DummyFilterSchema()
+    q = filter_instance.get_filter_expression()
+    assert q == Q(tag1=None) & Q(tag2=None)
+
+
+def test_field_level_custom_expression():
+    class DummyFilterSchema(FilterSchema):
+        name: Optional[str]
+        popular: Optional[bool]
+
+        def filter_popular(self, value):
+            return Q(downloads__gt=100) | Q(view_count__gt=1000) if value else Q()
+
+    filter_instance = DummyFilterSchema(name="foo", popular=True)
+    q = filter_instance.get_filter_expression()
+    assert q == Q(name="foo") & (Q(downloads__gt=100) | Q(view_count__gt=1000))
+
+    filter_instance = DummyFilterSchema(name="foo")
+    q = filter_instance.get_filter_expression()
+    assert q == Q(name="foo")
+
+    filter_instance = DummyFilterSchema()
+    q = filter_instance.get_filter_expression()
+    assert q == Q()
+
+
+def test_class_level_custom_expression():
+    class DummyFilterSchema(FilterSchema):
+        adult: Optional[bool] = Field(q="this_will_be_ignored")
+
+        def custom_expression(self) -> Q:
+            return Q(age__gte=18) if self.adult is True else Q()
+
+    filter_instance = DummyFilterSchema(adult=True)
+    q = filter_instance.get_filter_expression()
+    assert q == Q(age__gte=18)
+
+
+def test_filter_called():
+    class DummyFilterSchema(FilterSchema):
+        name: Optional[str] = Field(q="name")
+
+    filter_instance = DummyFilterSchema(name="foobar")
+    queryset = FakeQS()
+    queryset = filter_instance.filter(queryset)
+    assert queryset.filtered