Filtering🔗

In this section, we'll cover the everything necessary for adding filtering for your QueryTypes.

FilterSet🔗

A FilterSet is a collection of Filter objects that can be applied to a QueryType. In GraphQL, they represent an InputObjectType, which when added to a QueryType creates an input argument for filtering the results of a QueryType.

A basic FilterSet is created by subclassing FilterSet and adding its Django Model as a generic type parameter:

1
2
3
4
5
6
7
8
9
from undine import FilterSet, QueryType

from .models import Task


class TaskFilterSet(FilterSet[Task]): ...


class TaskType(QueryType[Task], filterset=TaskFilterSet): ...

Auto-generation🔗

By default, a FilterSet automatically introspects its model and converts the model's fields to input fields on the generated InputObjectType. Given the following models:

from django.db import models


class Project(models.Model):
    name = models.CharField(max_length=255)


class Task(models.Model):
    name = models.CharField(max_length=255)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="tasks")

Simply subclassing FilterSet creates an InputObjectType which has all of the Task model's fields and those fields' lookups translated into input arguments, as well as the logical operators NOT, AND, OR, XOR, allowing users to freely create any filtering conditions they want. The actual InputObjectType is omitted here for brevity, since it has quite many fields.

Let's give a few examples on how to use it. Assuming we added the TaskFilterSet to a QueryType named TaskType, which in turn has been added to the GraphQL schema as a list Entrypoint tasks, we can filter to only tasks that are done:

1
2
3
4
5
6
7
8
9
query {
  tasks(
    filter: {
      done: true
    }
  ) {
    name
  }
}

To see done tasks in a certain project:

query {
  tasks(
    filter: {
      done: true
      project: 1
    }
  ) {
    name
  }
}

To see all tasks that either start with "a" or end with "a":

query {
  tasks(
    filter: {
      OR {
        nameStartsWith: "a"
        nameEndsWith: "a"
      }
    }
  ) {
    name
  }
}
About Filter names

Usually the names of the Filters generated by auto-generation correspond to the lookup in Django, but for text-based fields, names are changed slightly to lean towards using case-insensitive lookups first: Filter name uses __iexact and nameExact uses __exact. Similarly, nameStartsWith uses __istartswith while nameStartsWithExact uses __startswith, etc.

You can disable auto-generation by setting the auto argument to False in the class definition:

1
2
3
4
5
6
7
from undine import FilterSet

from .models import Task


# This would create an empty `InputObjectType`, which is not allowed in GraphQL.
class TaskFilterSet(FilterSet[Task], auto=False): ...

Alternatively, you could exclude some Filters from the auto-generation by setting the exclude argument:

1
2
3
4
5
6
from undine import FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task], exclude=["created_at"]): ...

You can exclude either a model field (created_at) or a specific lookup on that model field (created_at_gte, not created_at__gte).

Filter queryset🔗

Together with its Filters, a FilterSet also provides a __filter_queryset__ classmethod. This method can be used to add filtering that should always be applied when fetching objects through QueryTypes using this FilterSet.

from django.db.models import QuerySet

from undine import FilterSet, GQLInfo

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    @classmethod
    def __filter_queryset__(cls, queryset: QuerySet, info: GQLInfo) -> QuerySet:
        if not info.context.user.is_staff:
            return queryset.none()
        return queryset

As QueryTypes also have a __filter_queryset__ classmethod, its important to note the order in which these are applied by the Optimizer.

Schema name🔗

By default, the name of the generated InputObjectType is the same as the name of the FilterSet class. If you want to change the name, you can do so by setting the schema_name argument:

1
2
3
4
5
6
from undine import FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task], schema_name="TaskFilterInput"): ...

Description🔗

You can provide a description using the description argument.

1
2
3
4
5
6
7
from undine import FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    """Description."""

Directives🔗

You can add directives to the FilterSet by providing them using the directives argument.

from graphql import DirectiveLocation

from undine import FilterSet
from undine.directives import Directive

from .models import Task


class MyDirective(Directive, locations=[DirectiveLocation.INPUT_OBJECT]): ...


class TaskFilterSet(FilterSet[Task], directives=[MyDirective()]): ...

See the Directives section for more details on directives.

GraphQL extensions🔗

You can provide custom extensions for the FilterSet by providing a extensions argument with a dictionary containing them. These can then be used however you wish to extend the functionality of the FilterSet.

1
2
3
4
5
6
from undine import FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task], extensions={"foo": "bar"}): ...

FilterSet extensions are made available in the GraphQL InputObjectType extensions after the schema is created. The FilterSet itself is found in the extensions under a key defined by the FILTERSET_EXTENSIONS_KEY setting.

Filter🔗

A Filter is a class that is used to define a possible filter input for a FilterSet. Usually Filters correspond to fields on the Django model for their respective FilterSet. In GraphQL, it represents an GraphQLInputField in an InputObjectType.

A Filter always requires a reference from which it will create the proper GraphQL resolver, input type for the Filter.

Model field references🔗

As seen in the FilterSet section, you don't need to provide model fields explicitly thanks to auto-generation, but if you wanted to be more explicit, you could add the Filters to the FilterSet class body. In this case, the Filter can be used without a reference, as its attribute name in the FilterSet class body can be used to identify the corresponding model field.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter()

To be a bit more explicit, you could use a string referencing the model field:

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter("name")

For better type safety, you can also use the model field itself:

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(Task.name)

Being explicit like this is only required if the name of the attribute in the GraphQL schema is different from the model field name.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    title = Filter("name")

Expression references🔗

Django ORM expressions can also be used as the references. These create an alias in the queryset when the Filter is used.

1
2
3
4
5
6
7
8
9
from django.db.models.functions import Upper

from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name_upper = Filter(Upper("name"))

Remember that subqueries are also counted as expressions.

from django.db.models import OuterRef

from undine import Filter, FilterSet
from undine.utils.model_utils import SubqueryCount

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    copies = Filter(SubqueryCount(Task.objects.filter(name=OuterRef("name"))))

Function references🔗

Functions (or methods) can also be used to create Filters. This can be done by decorating a method with the Filter class.

from django.db.models import Q

from undine import Filter, FilterSet, GQLInfo

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    @Filter
    def name(self, info: GQLInfo, *, value: str) -> Q:
        return Q(name__iexact=value)

The Filter method should return a Q expression. The type of the value argument is used as the input type for the Filter, so typing it is required.

About method signature

The decorated method is treated as a static method by the Filter.

The self argument is not an instance of the FilterSet, but the instance of the Filter that is being used.

The info argument can be left out, but if it's included, it should always have the GQLInfo type annotation.

The value argument is the value provided for the filter. It should always be named "value", and is required to be a keyword only argument.

Lookup🔗

By default, when defining a Filter on a FilterSet, the "exact" lookup expression is used. This can be changed by providing the lookup argument to the Filter.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(lookup="icontains")

Note that auto-generation adds Filters all possible combinations of lookups for a model field, so you don't need to add them manually.

Many🔗

The many argument changes the behavior of the Filter such that it takes a list of values instead of a single value. Then, each of the values are combined as defined by the match argument to form a single filter condition.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(many=True, lookup="icontains")

This would create the following filter input (ignoring auto-generation):

1
2
3
input TaskFilterSet {
  name: [String!]
}

So if a query is filtered using this filter with the value ["foo", "bar"], the filter condition would be Q(name__icontains="foo") | Q(name__icontains="bar").

Match🔗

The match changes the behavior of the many argument to combine the provided values with a different operation. The default is any, which means that the filter condition will include an item if it matches any of the provided values.

The match argument can be set to all if all of the values should match, or one_of if only one of the values should match.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(many=True, lookup="icontains", match="all")

Distinct🔗

If using some Filter would require a call to queryset.distinct() (e.g. lookups spanning "to-many" relations), you can use the distinct argument to tell the FilterSet for the Filter to do that if the Filter is used in a query.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(distinct=True)

Required🔗

By default, all Filter are not required (nullable in GraphQL terms). If you want to make a Filter required, you can do so by setting the required argument to True.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(required=True)

Required aliases🔗

The required_aliases argument can be used to specify additional expressions that should be added as aliases to the queryset when the Filter is used. This is useful as a way to make the actual Filter more readable when complex expressions are needed.

from django.db.models import F, OuterRef, Q

from undine import Filter, FilterSet
from undine.utils.model_utils import SubqueryCount

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    has_more_copies = Filter(
        Q(copies__gt=F("non_copies")),
        required_aliases={
            "copies": SubqueryCount(Task.objects.filter(name=OuterRef("name"))),
            "non_copies": SubqueryCount(Task.objects.exclude(name=OuterRef("name"))),
        },
    )

Field name🔗

A field_name can be provided to explicitly set the Django model field name that the Filter corresponds to. This can be useful when the field has a different name and type in the GraphQL schema than in the model.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    field_name = Filter(field_name="name")

Schema name🔗

A schema_name can be provided to override the name of the Filter in the GraphQL schema. This can be useful for renaming fields for the schema, or when the desired name is a Python keyword and cannot be used as the Filter attribute name.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(schema_name="title")

Description🔗

By default, a Filter is able to determine its description based on its reference. For example, for a model field, the description is taken from its help_text.

If the reference has no description, or you wish to add a different one, this can be done in two ways:

1) By setting the description argument.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(description="Get only tasks with the given name.")

2) As class variable docstrings.

1
2
3
4
5
6
7
8
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter()
    """Get only tasks with the given name."""

When using function references, instead of a class variable docstring, you add a docstring to the function/method used as the reference instead.

from django.db.models import Q

from undine import Filter, FilterSet, GQLInfo

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    @Filter
    def name(self, info: GQLInfo, *, value: str) -> Q:
        """Get only tasks with the given name."""
        return Q(name__iexact=value)

Deprecation reason🔗

A deprecation_reason can be provided to mark the Filter as deprecated. This is for documentation purposes only, and does not affect the use of the Filter.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(deprecation_reason="Use something else.")

Empty filter result🔗

A special EmptyFilterResult exception can be raised from a Filter to indicate that the usage of the Filter should result in an empty queryset, e.g. because of the value given or for permission reasons.

from undine import Filter, FilterSet, GQLInfo
from undine.exceptions import EmptyFilterResult

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    @Filter
    def name(self, info: GQLInfo, *, value: str) -> bool:
        if value == "secret":
            raise EmptyFilterResult
        return True

Raising this exceptions skips the rest of the Filter logic and results in an empty queryset.

Permissions🔗

You can add permissions check to individual Filters by using a custom function and adding the permission check inline.

from django.db.models import Q

from undine import Filter, FilterSet, GQLInfo
from undine.exceptions import GraphQLPermissionError

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    @Filter
    def name(self, info: GQLInfo, *, value: str) -> Q:
        if not info.context.user.is_authenticated:
            msg = "Only authenticated users can filter by task names."
            raise GraphQLPermissionError(msg)

        return Q(name__icontains=value)

You can also raise the EmptyFilterResult exception if the usage of the filter should result in an empty queryset instead of an error.

Directives🔗

You can add directives to the Filter by providing them using the directives argument.

from graphql import DirectiveLocation

from undine import Filter, FilterSet
from undine.directives import Directive

from .models import Task


class MyDirective(Directive, locations=[DirectiveLocation.INPUT_FIELD_DEFINITION]): ...


class TaskFilterSet(FilterSet[Task]):
    name = Filter(directives=[MyDirective()])

See the Directives section for more details on directives.

GraphQL extensions🔗

You can provide custom extensions for the Filter by providing a extensions argument with a dictionary containing them. These can then be used however you wish to extend the functionality of the Filter.

1
2
3
4
5
6
7
from undine import Filter, FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter(extensions={"foo": "bar"})

Filter extensions are made available in the GraphQL InputField extensions after the schema is created. The Filter itself is found in the extensions under a key defined by the FILTER_EXTENSIONS_KEY setting.