Skip to content

Schema🔗

In this section, we'll cover how you can set up entrypoints to you GraphQL schema for executing operations in Undine.

RootTypes🔗

A GraphQL schema defines a RootType for each kind of operation that it supports. In GraphQL terms, a RootType is just a regular ObjectType that just happens to be the root of the GraphQL Schema.

Let's take a look at this example from the Tutorial.

from undine import Entrypoint, RootType, create_schema


class Query(RootType):
    @Entrypoint
    def testing(self) -> str:
        return "Hello, World!"


schema = create_schema(query=Query)

Here you've created the Query RootType. In Undine, the Query RootType is required to exist for a schema to be created. Each RootType must also have at least one Entrypoint in its class body.

As the name implies, the Query RootType is for querying data. For mutating data, you'd create a Mutation RootType.

from pathlib import Path

from undine import Entrypoint, RootType, create_schema


class Query(RootType):
    @Entrypoint
    def testing(self) -> str:
        return "Hello, World!"


class Mutation(RootType):
    @Entrypoint
    def testing(self) -> int:
        return Path("foo.txt").write_text("Hello, World!", encoding="utf-8")


schema = create_schema(query=Query, mutation=Mutation)

The Mutation RootType is optional, but if created, it must also include at least one Entrypoint, just like the Query RootType.

For Subscription RootTypes, see the Subscriptions section.

Schema name🔗

By default, the name of the generated GraphQL ObjectType from a RootType class is the name of the RootType class. If you need to change the name separately, you can do so by providing the schema_name argument.

1
2
3
4
5
6
7
from undine import Entrypoint, RootType


class Query(RootType, schema_name="MyQuery"):
    @Entrypoint
    def testing(self) -> str:
        return "Hello, World!"

Description🔗

To provide a description for the RootType, you can add a docstring to the class.

1
2
3
4
5
6
7
8
9
from undine import Entrypoint, RootType


class Query(RootType):
    """Operations for querying."""

    @Entrypoint
    def testing(self) -> str:
        return "Hello, World!"

Directives🔗

You can add directives to the RootType by providing them using the directives argument. The directive must be usable in the OBJECT location.

from graphql import DirectiveLocation

from undine import Directive, Entrypoint, RootType


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


class Query(RootType, directives=[MyDirective()]):
    @Entrypoint
    def testing(self) -> str:
        return "Hello, World!"

You can also add them using decorator syntax.

from graphql import DirectiveLocation

from undine import Directive, Entrypoint, RootType


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


@MyDirective()
class Query(RootType):
    @Entrypoint
    def testing(self) -> str:
        return "Hello, World!"

See the Directives section for more details on directives.

GraphQL extensions🔗

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

1
2
3
4
5
6
7
from undine import Entrypoint, RootType


class Query(RootType, extensions={"foo": "bar"}):
    @Entrypoint
    def testing(self) -> str:
        return "Hello, World!"

RootType extensions are made available in the GraphQL ObjectType extensions after the schema is created. The RootType itself is found in the GraphQL ObjectType extensions under a key defined by the ROOT_TYPE_EXTENSIONS_KEY setting.

Entrypoints🔗

Entrypoints can be thought of as the "API endpoints inside the GraphQL schema" from which you can execute operations like queries or mutations. In GraphQL terms, they are the fields on the ObjectType created from a RootType

An Entrypoint always requires a reference from which it will create the proper GraphQL resolver, output type, and arguments for the operation.

Function references🔗

Using a function/method as a reference is the most basic way of creating an Entrypoint.

Function references can be used for both query and mutation Entrypoints. See the example from the Tutorial.

1
2
3
4
5
6
7
from undine import Entrypoint, GQLInfo, RootType


class Query(RootType):
    @Entrypoint
    def testing(self, info: GQLInfo) -> str:
        return "Hello World!"

With a function reference, the Entrypoint will use the decorated function as its GraphQL resolver. The function's return type will be used as the Entrypoint's output type, so typing it is required. You can even use a TypedDict to return an object with multiple fields.

About method signature

A method decorated with @Entrypoint is treated as a static method by the Entrypoint.

The self argument is not an instance of the RootType, but root argument of the GraphQLField resolver. To clarify this, it's recommended to change the argument's name to root, as defined by the RESOLVER_ROOT_PARAM_NAME setting.

The value of the root argument for an Entrypoint is None by default, but can be configured using the ROOT_VALUE setting if desired.

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

You can add arguments to the Entrypoint by adding them to the function signature. Typing these arguments is required to determine their input type.

1
2
3
4
5
6
7
from undine import Entrypoint, RootType


class Query(RootType):
    @Entrypoint
    def testing(self, name: str) -> str:
        return f"Hello, {name}!"

This will add a non-null name string argument to the Entrypoint. Note that non-null arguments are required by GraphQL, so if you wanted to make the argument optional, you'd need to make it nullable (in which case it will be None by default) or add a default value ourselves.

1
2
3
4
5
6
7
from undine import Entrypoint, RootType


class Query(RootType):
    @Entrypoint
    def testing(self, name: str | None = None) -> str:
        return f"Hello, {name or 'World'}!"

You can add a description to the Entrypoint by adding a docstring to the method. If the method has arguments, you can add descriptions to those arguments by using reStructuredText docstrings format.

from undine import Entrypoint, RootType


class Query(RootType):
    @Entrypoint
    def testing(self, name: str) -> str:
        """
        Return a greeting.

        :param name: The name to greet.
        """
        return f"Hello, {name}!"
What about other docstring formats?

Other types of docstrings can be used by parsed by providing a custom parser to the DOCSTRING_PARSER setting that conforms to the DocstringParserProtocol from undine.typing.

QueryType references🔗

A QueryType represents a GraphQL ObjectType for querying data from a Django Model. You can read more on QueryTypes in the Queries section. This section will only cover using them in Entrypoints.

To create an Entrypoint for querying a single Model instance by its primary key, simply use the QueryType class as the reference for the Entrypoint.

from undine import Entrypoint, Field, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()


class Query(RootType):
    task = Entrypoint(TaskType)

This would create the following field in the Query RootType:

1
2
3
type Query {
    task(pk: Int!): TaskType
}

To crete an Entrypoint for listing all instances of the Model, add the many argument to the Entrypoint.

from undine import Entrypoint, Field, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()


class Query(RootType):
    tasks = Entrypoint(TaskType, many=True)

This would create the following field in the Query RootType:

1
2
3
type Query {
    tasks: [TaskType!]!
}

With a list Entrypoint, if a FilterSet or an OrderSet has been added to your QueryType, they will show up as arguments on the Entrypoint.

1
2
3
4
5
6
type Query {
  tasks(
    filter: TaskFilterSet
    orderBy: [TaskOrderSet!]
  ): [TaskType!]!
}

MutationType references🔗

A MutationType represents a possible mutation operation based on a Django Model. You can read more on MutationTypes in the Mutations section. This section will only cover using them in Entrypoints.

To create an Entrypoint for mutating a single Model instance (a create mutation in this example), simply use the MutationType class as the reference for the Entrypoint.

from undine import Entrypoint, Field, Input, MutationType, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()


class TaskCreateMutation(MutationType[Task]):
    name = Input()
    done = Input()


class Mutation(RootType):
    create_task = Entrypoint(TaskCreateMutation)

This would create the following field in the Mutation RootType:

1
2
3
type Mutation {
    createTask(input: TaskCreateMutation!): TaskType!
}

To create an Entrypoint for mutating multiple Model instances in bulk, add the many argument to the Entrypoint.

from undine import Entrypoint, Field, Input, MutationType, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()


class TaskCreateMutation(MutationType[Task]):
    name = Input()
    done = Input()


class Mutation(RootType):
    bulk_create_tasks = Entrypoint(TaskCreateMutation, many=True)

This would create the following field in the Mutation RootType:

1
2
3
type Mutation {
    bulkCreateTask(input: [TaskCreateMutation!]!): [TaskType!]!
}

Note that the total amount of objects that can be mutated in a single request is limited by the MUTATION_INSTANCE_LIMIT setting. See mutation instance limit.

Nullable🔗

By default, all Entrypoints are non-null (except for function references, which determine nullability from the function's signature). However, you can make an Entrypoint nullable explicitly by using the nullable argument.

from undine import Entrypoint, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    task = Entrypoint(TaskType, nullable=True)

Limit🔗

The limit argument is used by Entrypoints based on either QueryTypes, UnionTypes, or InterfaceTypes that return a list of items (i.e. many=True) to limit the number of objects that are fetched. By default, this is set by the LIST_ENTRYPOINT_LIMIT setting. A paginated Entrypoint returns the page its own arguments ask for, so the limit argument does not apply to it.

from undine import Entrypoint, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    tasks = Entrypoint(TaskType, many=True, limit=100)

Permissions🔗

To add permission checks to your Entrypoint, use the @<entrypoint_name>.permissions decorator.

from undine import Entrypoint, GQLInfo, QueryType, RootType
from undine.exceptions import GraphQLPermissionError

from .models import Task


class TaskType(QueryType[Task]):
    @classmethod
    def __permissions__(cls, instance: Task, info: GQLInfo) -> None:
        # Not called if 'TaskType' is accessed from 'Query.task'
        # because it has a permissions check already
        if not info.context.user.is_superuser:
            raise GraphQLPermissionError


class Query(RootType):
    task = Entrypoint(TaskType)

    @task.permissions
    def task_permissions(self, info: GQLInfo, instance: Task) -> None:
        if info.context.user.is_authenticated:
            msg = "Need to be logged in to access Tasks."
            raise GraphQLPermissionError(msg)

Note that permissions for Entrypoints based on QueryTypes or MutationTypes are checked using that QueryType's or MutationType's permissions if no permission checks have been defined on the Entrypoint.

Custom resolver🔗

You can override the resolver for an Entrypoint by decorating a method using the @<entrypoint_name>.resolve decorator. This can be used, e.g., to add special-case Entrypoints for QueryTypes.

from undine import Entrypoint, GQLInfo, QueryType, RootType
from undine.optimizer import optimize_sync

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    task_by_name = Entrypoint(TaskType, nullable=True)

    @task_by_name.resolve
    def resolve_task_by_name(self, info: GQLInfo, name: str) -> Task | None:
        return optimize_sync(Task.objects.all(), info, name=name)
About method signature

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

The self argument is not an instance of the RootType, but root argument of the GraphQLField resolver. To clarify this, it's recommended to change the argument's name to root, as defined by the RESOLVER_ROOT_PARAM_NAME setting.

The value of the root argument for an Entrypoint is None by default, but can be configured using the ROOT_VALUE setting if desired.

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

Note that when using this decorator, you'll override the resolver and arguments based on the reference used in the Entrypoint. Arguments will be taken from the additional arguments passed to the resolver, e.g., "name" in the example above.

When overriding the resolver for Entrypoints based on QueryTypes, the QueryType's FilterSet and OrderSet will not be available on the Entrypoint

Overriding the resolver for Entrypoints using MutationTypes is not recommended, as it bypasses the whole mutation process and many MutationType functions will not work.

If the resolver returns a Django Model that resolves using QueryType, you should call the optimizer in the resolver using optimize_sync or optimize_async, like in the above example, so that queries are optimized.

Schema name🔗

By default, the name of the ObjectType field generated from an Entrypoint is the same as the name of the Entrypoint on the RootType class (converted to camelCase if CAMEL_CASE_SCHEMA_FIELDS is enabled). If you want to change the name of the ObjectType field separately, you can do so by setting the schema_name argument:

from undine import Entrypoint, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    task = Entrypoint(TaskType, schema_name="singleTask")

This can be useful when the desired name of the ObjectType field is a Python keyword and cannot be used as the Entrypoint attribute name.

Description🔗

By default, an Entrypoint is able to determine its description based on its reference. For example, for a QueryType, the description is taken from the class docstring. If the reference has no description, or you wish to add a different one, you can provide a description in one of two ways:

1) By setting the description argument.

from undine import Entrypoint, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    task = Entrypoint(TaskType, description="Fetch a single Task.")

2) As class attribute docstrings, if ENABLE_CLASS_ATTRIBUTE_DOCSTRINGS is enabled.

from undine import Entrypoint, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    task = Entrypoint(TaskType)
    """Fetch a single Task."""

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

Deprecation reason🔗

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

from undine import Entrypoint, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    task = Entrypoint(TaskType, deprecation_reason="Use something else.")

Complexity🔗

The complexity value of an Entrypoint is used by Undine to calculate how expensive a given query to the schema would be. Queries are rejected by Undine if they would exceed the maximum allowed complexity, as set by the MAX_QUERY_COMPLEXITY setting.

By default, an Entrypoint is able to determine its complexity based on its reference. An Entrypoint that runs a database query, like one based on a QueryType, has a complexity of 1. A MutationType counts as 1, but if its return type is a QueryType, that QueryType's complexity is counted as well. An Entrypoint that runs none, like one based on a function, has a complexity of 0.

An Entrypoint based on a UnionType or an InterfaceType runs one query for each member it fetches, so it adds 1 for each member the operation selects fields from. Selecting a field on the interface itself fetches every implementation, since the field is read from all of them.

You can also set the complexity on the Entrypoint yourself. This is useful for an Entrypoint whose reference does not describe its cost, like one that calls an external service. Note that this complexity adds to any complexity calculated from the QueryType's Fields.

Caching🔗

You can cache the response of an Entrypoint using the cache_time and cache_per_user arguments.

1
2
3
4
5
6
7
from undine import Entrypoint, RootType


class Query(RootType):
    @Entrypoint(cache_time=60)
    def testing(self, name: str) -> str:
        return f"Hello, {name}!"

See the Caching section for more details.

Errors as data🔗

You can use an Entrypoint's errors argument to define a list of Exceptions that can be raised by the Entrypoint and have them represented as a union in the schema instead of following the GraphQL error handling rules defined in the GraphQL spec. See Field errors as data section for more details.

import random

from graphql import GraphQLError

from undine import Entrypoint, RootType


class Query(RootType):
    @Entrypoint(errors=[GraphQLError])
    def example(self) -> str:
        if random.random() > 0.5:
            msg = "Failed"
            raise GraphQLError(msg)
        return "OK"

Directives🔗

You can add directives to the Entrypoint by providing them using the directives argument. The directive must be usable in the FIELD_DEFINITION location.

from graphql import DirectiveLocation

from undine import Directive, Entrypoint, QueryType, RootType

from .models import Task


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


class TaskType(QueryType[Task]): ...


class Query(RootType):
    task = Entrypoint(TaskType, directives=[MyDirective()])

You can also add them using the @ operator (which kind of looks like GraphQL syntax):

from graphql import DirectiveLocation

from undine import Directive, Entrypoint, QueryType, RootType

from .models import Task


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


class TaskType(QueryType[Task]): ...


class Query(RootType):
    task = Entrypoint(TaskType) @ MyDirective()

See the Directives section for more details on directives.

GraphQL extensions🔗

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

from undine import Entrypoint, QueryType, RootType

from .models import Task


class TaskType(QueryType[Task]): ...


class Query(RootType):
    task = Entrypoint(TaskType, extensions={"foo": "bar"})

Entrypoint extensions are made available in the GraphQL ObjectType field extensions after the schema is created. The Entrypoint itself is found in the GraphQL field extensions under a key defined by the ENTRYPOINT_EXTENSIONS_KEY setting.

Schema export🔗

Undine includes a management command to export your GraphQL schema. It prints the schema to STDOUT, which can be redirected to a file like so:

python manage.py print_schema > schema.graphql

The command builds the schema before printing it, so it also exits with a non-zero status if the schema fails to build. That makes it useful as a standalone smoke test in CI.

Federated subgraphs🔗

For a schema built with create_federation_schema, print_schema prints the subgraph SDL as served by Query._service, which leaves out the federation machinery (_Any, _Entity, _Service, _entities, _service), leaves out the directive and scalar definitions that the @link directive provides, and uses extend schema instead of schema. See the Federation section for more details.

Visibility🔗

Exported SDL is not affected by visibility. print_schema always emits the full schema, even for elements that are hidden from every user. This matches the behavior of _service { sdl }, and it is what you want for an export: a schema registry needs a stable, request-independent view of the schema.

Checking for schema drift🔗

If you commit the exported SDL to your repository, the --check option verifies that the file still matches the current schema. It exits with a non-zero status if they differ.

python manage.py print_schema --check

The option takes an optional path, defaulting to schema.graphql in the current directory.

python manage.py print_schema --check path/to/schema.graphql