Filtering๐Ÿ”—

In this section, we'll cover the everything necessary for filtering results returned by 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. Then, the FilterSet can be added to a QueryType using the filterset argument.

from undine import Field, Filter, FilterSet, QueryType

from .models import Task


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


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

You can also add the FilterSet using decorator syntax.

from undine import Field, Filter, FilterSet, QueryType

from .models import Task


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


@TaskFilterSet
class TaskType(QueryType[Task]):
    name = Field()

Auto-generation๐Ÿ”—

A FilterSet can automatically introspect its Django model and convert the model's fields to Filters on the FilterSet. For example, if the Task model has the following fields:

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")

An auto-generated FilterSet will have all of the Task model's fields and those fields' lookups translated into input arguments.

Here is the generated InputObjectType
input TaskFilterSet {
  createdAt: DateTime
  createdAtDate: Date
  createdAtDateGt: Date
  createdAtDateGte: Date
  createdAtDateIn: [Date!]
  createdAtDateLt: Date
  createdAtDateLte: Date
  createdAtDateRange: [Date!]
  createdAtDay: Int
  createdAtDayContains: Int
  createdAtDayEndsWith: Int
  createdAtDayGt: Int
  createdAtDayGte: Int
  createdAtDayIn: [Int!]
  createdAtDayLt: Int
  createdAtDayLte: Int
  createdAtDayRange: [Int!]
  createdAtDayStartsWith: Int
  createdAtGt: DateTime
  createdAtGte: DateTime
  createdAtHour: Int
  createdAtHourContains: Int
  createdAtHourEndsWith: Int
  createdAtHourGt: Int
  createdAtHourGte: Int
  createdAtHourIn: [Int!]
  createdAtHourLt: Int
  createdAtHourLte: Int
  createdAtHourRange: [Int!]
  createdAtHourStartsWith: Int
  createdAtIn: [DateTime!]
  createdAtIsoWeekDay: Int
  createdAtIsoWeekDayContains: Int
  createdAtIsoWeekDayEndsWith: Int
  createdAtIsoWeekDayGt: Int
  createdAtIsoWeekDayGte: Int
  createdAtIsoWeekDayIn: [Int!]
  createdAtIsoWeekDayLt: Int
  createdAtIsoWeekDayLte: Int
  createdAtIsoWeekDayRange: [Int!]
  createdAtIsoWeekDayStartsWith: Int
  createdAtIsoYear: Int
  createdAtIsoYearContains: Int
  createdAtIsoYearEndsWith: Int
  createdAtIsoYearGt: Int
  createdAtIsoYearGte: Int
  createdAtIsoYearIn: [Int!]
  createdAtIsoYearLt: Int
  createdAtIsoYearLte: Int
  createdAtIsoYearRange: [Int!]
  createdAtIsoYearStartsWith: Int
  createdAtLt: DateTime
  createdAtLte: DateTime
  createdAtMinute: Int
  createdAtMinuteContains: Int
  createdAtMinuteEndsWith: Int
  createdAtMinuteGt: Int
  createdAtMinuteGte: Int
  createdAtMinuteIn: [Int!]
  createdAtMinuteLt: Int
  createdAtMinuteLte: Int
  createdAtMinuteRange: [Int!]
  createdAtMinuteStartsWith: Int
  createdAtMonth: Int
  createdAtMonthContains: Int
  createdAtMonthEndsWith: Int
  createdAtMonthGt: Int
  createdAtMonthGte: Int
  createdAtMonthIn: [Int!]
  createdAtMonthLt: Int
  createdAtMonthLte: Int
  createdAtMonthRange: [Int!]
  createdAtMonthStartsWith: Int
  createdAtQuarter: Int
  createdAtQuarterContains: Int
  createdAtQuarterEndsWith: Int
  createdAtQuarterGt: Int
  createdAtQuarterGte: Int
  createdAtQuarterIn: [Int!]
  createdAtQuarterLt: Int
  createdAtQuarterLte: Int
  createdAtQuarterRange: [Int!]
  createdAtQuarterStartsWith: Int
  createdAtRange: [DateTime!]
  createdAtSecond: Int
  createdAtSecondContains: Int
  createdAtSecondEndsWith: Int
  createdAtSecondGt: Int
  createdAtSecondGte: Int
  createdAtSecondIn: [Int!]
  createdAtSecondLt: Int
  createdAtSecondLte: Int
  createdAtSecondRange: [Int!]
  createdAtSecondStartsWith: Int
  createdAtTime: Time
  createdAtTimeContains: Time
  createdAtTimeEndsWith: Time
  createdAtTimeGt: Time
  createdAtTimeGte: Time
  createdAtTimeIn: [Time!]
  createdAtTimeLt: Time
  createdAtTimeLte: Time
  createdAtTimeRange: [Time!]
  createdAtTimeStartsWith: Time
  createdAtWeek: Int
  createdAtWeekContains: Int
  createdAtWeekDay: Int
  createdAtWeekDayContains: Int
  createdAtWeekDayEndsWith: Int
  createdAtWeekDayGt: Int
  createdAtWeekDayGte: Int
  createdAtWeekDayIn: [Int!]
  createdAtWeekDayLt: Int
  createdAtWeekDayLte: Int
  createdAtWeekDayRange: [Int!]
  createdAtWeekDayStartsWith: Int
  createdAtWeekEndsWith: Int
  createdAtWeekGt: Int
  createdAtWeekGte: Int
  createdAtWeekIn: [Int!]
  createdAtWeekLt: Int
  createdAtWeekLte: Int
  createdAtWeekRange: [Int!]
  createdAtWeekStartsWith: Int
  createdAtYear: Int
  createdAtYearContains: Int
  createdAtYearEndsWith: Int
  createdAtYearGt: Int
  createdAtYearGte: Int
  createdAtYearIn: [Int!]
  createdAtYearLt: Int
  createdAtYearLte: Int
  createdAtYearRange: [Int!]
  createdAtYearStartsWith: Int
  done: Boolean
  name: String
  nameContains: String
  nameContainsExact: String
  nameEndsWith: String
  nameEndsWithExact: String
  nameExact: String
  nameIn: [String!]
  nameStartsWith: String
  nameStartsWithExact: String
  pk: Int
  pkContains: Int
  pkEndsWith: Int
  pkGt: Int
  pkGte: Int
  pkIn: [Int!]
  pkLt: Int
  pkLte: Int
  pkRange: [Int!]
  pkStartsWith: Int
  project: Int
  projectGt: Int
  projectGte: Int
  projectIn: [Int!]
  projectIsNull: Boolean
  projectLt: Int
  projectLte: Int
  NOT: TaskFilterSet
  AND: TaskFilterSet
  OR: TaskFilterSet
  XOR: TaskFilterSet
}
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.

To use auto-generation, either set AUTOGENERATION setting to True to enable it globally, or set the auto argument to True in the FilterSet class definition. With this, you can leave the FilterSet class body empty.

1
2
3
4
5
6
from undine import FilterSet

from .models import Task


class TaskFilterSet(FilterSet[Task], auto=True): ...

You can exclude some model fields 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], auto=True, exclude=["created_at"]): ...

You can also exclude specific model lookups, e.g. created_at__gte.

Logical operators๐Ÿ”—

A FilterSet always provides the logical operators NOT, AND, OR, XOR, allowing users to freely create more complex conditions from defined filters. Let's assume you've added an auto-generated TaskFilterSet to a QueryType named TaskType. Normally, when multiple filter's are used, we'll get results where all results match all filters.

query {
  tasks(
    filter: {
      nameStartsWith: "a"
      done: true
    }
  ) {
    name
  }
}

However, by adding the filter to an OR block, we can get results where any of the conditions match:

query {
  tasks(
    filter: {
      OR: {
        nameStartsWith: "a"
        done: true
      }
    }
  ) {
    name
  }
}

Note that only the results inside the conditional block will use that logical combinator. For example, in the following example, only tasks that contains an "e" AND EITHER start with "a" OR are done will be returned:

query {
  tasks(
    filter: {
      nameContains: "e"
      OR: {
        nameStartsWith: "a"
        done: true
      }
    }
  ) {
    name
  }
}

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 Filter, FilterSet, GQLInfo

from .models import Task


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

    @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
7
from undine import Filter, FilterSet

from .models import Task


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

Description๐Ÿ”—

You can provide a description using the description argument.

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

from .models import Task


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

    name = Filter()

Directives๐Ÿ”—

You can add directives to the FilterSet 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_OBJECT]): ...


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

See the Directives section for more details on directives.

Visibility๐Ÿ”—

This is an experimental feature that needs to be enabled using the EXPERIMENTAL_VISIBILITY_CHECKS setting.

You can hide a FilterSet from certain users by adding the visible argument to the FilterSet. Hiding a filterset means that it will not be included in introspection queries for that user, and it cannot be used in operations by that user.

from undine import Filter, FilterSet
from undine.typing import DjangoRequestProtocol

from .models import Task


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

    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_superuser

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
7
from undine import Filter, FilterSet

from .models import Task


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

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๐Ÿ”—

For Filters corresponding to Django model fields, the Filter can be used without passing in 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")

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:

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)

Aliases๐Ÿ”—

Sometimes a Filter may require additional expressions to be added as aliases to the queryset when the Filter is used. For this, you can define a function that returns a dictionary of expressions and decorate it with the aliases decorator.

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

from undine import DjangoExpression, Filter, FilterSet, GQLInfo
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")))

    @has_more_copies.aliases
    def has_more_copies_aliases(self, info: GQLInfo, *, value: bool) -> dict[str, DjangoExpression]:
        return {
            "copies": SubqueryCount(Task.objects.filter(name=OuterRef("name"))),
            "non_copies": SubqueryCount(Task.objects.exclude(name=OuterRef("name"))),
        }

Empty values๐Ÿ”—

By default, Filters will ignore some values which are considered "empty" in the context of filtering. These values are set globally by the EMPTY_VALUES setting. Usually this is what you want, as it allows you to set default values in our GraphQL variables.

If you wish to change what's considered an empty value for an individual Filter, you can do so by setting the empty_values argument to a list of values.

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

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    # Allow filtering with the empty string or None
    title = Filter(empty_values=[])

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 attribute docstrings, if ENABLE_CLASS_ATTRIBUTE_DOCSTRINGS is enabled.

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 attribute 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.

Visibility๐Ÿ”—

This is an experimental feature that needs to be enabled using the EXPERIMENTAL_VISIBILITY_CHECKS setting.

You can hide a Filter from certain users by adding the visible argument to the Filter. Hiding a filter means that it will not be included in introspection queries for that user, and it cannot be used in operations by that user.

from undine import Filter, FilterSet
from undine.typing import DjangoRequestProtocol

from .models import Task


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

    @name.visible
    def name_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_superuser

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.