Default schema

DjraphQL examines a set of Django models and generates types that map to the types of a GraphQL API. The actual generation of those types is the concern of a schema.

DjraphQL ships with a single schema, which we’ll refer to as the default schema.

In this section, we’ll enumerate what types are generated for an entity class.

Note

We’ll use an asterisk (*) to represent the name of the model that the operation interacts with. For example, if we have an Entity whose nested meta class has model = Foo defined, a root query operation called FooByPk will be generated.

Root queries

Two root query objects can be generated for each entity. For instance, if we’ve defined an AlbumEntity for our Album model, our GraphQL schema will have the following root query objects.

The Entity class must be readable, i.e. access_permissions contains a Read() instance.

*ByPk

Args
  • pk is the primary key of the Album being fetched

Returns
  • Album object matching the given primary key

*sMany

Args
  • where: WhereAlbum can be used to craft a set of conditions in a SQL where-clause

  • orderBy: [OrderByAlbum] can define how the list is ordered

  • limit: Int can define how many items are returned. The default value is 50.

  • offset: Int can define how many items to skip within the resulting SQL query before returning the list of items. Can be used in conjunction with limit to paginate items.

Returns
  • [Album!] objects matching the provided filtering parameters

Root mutations

Depending on the values specified in an entity’s access_permissions field, there are three root mutation types for a given model.

insert*

The Entity class must be creatable, i.e. access_permissions contains a Create instance.

Args
  • data: AlbumInput! is the primary key of the Album being fetched

  • tag: String can be used to map inserted objects back to their tags via pksToTags

Returns
  • InsertAlbum result object of the insert mutation

This operation can insert nested objects as well. If we want to insert a Label and its Artists in the same request, we can do so.

mutation {
  insertLabel(
    tag: "label-tag"
    data: {
      name: "My Label"
      establishedYear: 2021
      artists: [
        {
          tag: "first-artist-tag",
          data: {name: "First Artist"}
        },
        {
          tag: "second-artist-tag",
          data: {name: "Second Artist"}
        }
      ]
    }
  ) {
    success
    pksToTags {
      pk
      tag
    }
    result {
      id
      artists {
        id
      }
    }
  }
}

Note

DjraphQL’s concept of tags are meant to answer the following question:

  • What is the primary-key of an inserted object with client-side identifier X?

The pksToTags field is a map of all tags given in the input to the primary-keys of their inserted objects.

So in our example above, the pksToTags field would look something like:

[{"tag": "label-tag", "pk": 1}, {"tag": "first-artist-tag", "pk": 1}, {"tag": "second-artist-tag", "pk": 2}]

In addition to tag and pk, there’s a third property, type, that indicates which model was inserted.

If your use case does not involve nested inserts or client-side IDs, you can largely ignore tags.

update*

The Entity class must be updatable, i.e. access_permissions contains an Update instance.

Args
  • pk (Int) is the primary key of the item being updated

  • data (AlbumInput!) is an object specifying the updated fields

Returns
  • UpdateAlbum result object of the update mutation

Similar to the insert mutation, this operation can update (and insert!) nested objects. If we want to update a Label and its Artists in the same request, we can do so.

mutation {
  updateLabel(
    pk: 1
    data: {
      name: "My Cooler Label"
      establishedYear: 3021
      artists: {
        updateStrategy: "merge"
        items: [
          {
             pk: 1,
             data: {name: "Cool Artist"}
          },
          {
             tag: "second-artist-tag"
             data: {name: "Cooler Artist"}
          }
        ]
      }
    }
  ) {
    success
    pksToTags {
      pk
      tag
    }
    result {
      id
      artists {
        id
      }
    }
  }
}

In this scenario, there’s more going on, so let’s walk through it.

First, our update mutation requires the pk of the item being updated. This also implies we don’t need to pass a tag (because we already know the PK!).

Then, for our nested update of artists field, note that we’re passing an object instead of a list. This is because we can choose how the update works via our updateStrategy. The choices are:

  • merge (default) updates the items with a pk and inserts items without a pk.

  • replace does the same thing as merge, but additionally deletes existing items that aren’t represented (by their pk) in the items list.

    • This option is quite useful in the case where we want our database to match our list exactly, but be careful because it is destructive!

Note

Errors will be thrown if a nested update attempts to perform an insert, update, or delete on a model whose entity class has not speecified the corresponding access permissions (Create(), Update(), Delete()).

Since we’ve chosen merge, our first artist item will be updated because we passed its primary-key (pk). The second artist will be inserted, and pksToTags will contain an entry for that insertion.

delete*

The Entity class must be deletable, i.e. access_permissions contains an Delete instance.

Args
  • pk (Int) is the primary key of the item being updated

Returns
  • DeleteAlbum result object of the delete mutation

Type mappings

A schema defines the mapping between Django model field types and the GraphQL types they are exposed as in the generated schema.

Below is that mapping:

{
    "AutoField": graphene.ID,
    "BigAutoField": graphene.ID,
    "IntegerField": graphene.Int,
    "PositiveIntegerField": graphene.Int,
    "BigIntegerField": graphene.Int,
    "ForeignKey": graphene.ID,
    "OneToOneField": graphene.ID,
    "ManyToManyField": graphene.ID,
    "DecimalField": graphene.Float,
    "FloatField": graphene.Float,
    "DateField": graphene.Date,
    "DateTimeField": graphene.DateTime,
    "CharField": graphene.String,
    "TextField": graphene.String,
    "BooleanField": graphene.Boolean,
    "NullBooleanField": graphene.Boolean,
}

Overriding the mapping

It is sometimes useful to override the mapping defined above. For example, if we want all primary- or foreign-key (*id) fields in our API to be Int instead of ID.

It is possible to accomplish this for an individual field by passing graphene_type to each ModelField that represents a relational field. But to do this for every field on every model is quite cumbersome.

Instead, we can instruct DjraphQL to treat all AutoField, OneToOneField, etc. as Int types by directly instantiating the schema object and passing it to the SchemaBuilder via the field_type_map_overrides keyword argument.

Please note there are issues with taking this approach specifically for BigAutoField. This is because these fields are 64-bit integers, but Graphene only represents 32-bit integers with its Int type. Instead, best practice would be to override BigAutoField to map to String and accomodate this serialization in your application.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from graphene import Int
from djraphql import SchemaBuilder
from djraphql.schemas.default import DefaultSchema

my_schema = DefaultSchema(
    field_type_map_overrides={
        'AutoField': Int,
        'BigAutoField': String,
        'OneToOneField': Int,
    }
)
schema_generator = SchemaBuilder(schema=my_schema)

Filtering via where

The schema generated by DjraphQL is quite flexible: it can craft arbitrarily complex WHERE and ORDER BY clauses as well as define a LIMIT and OFFSET.

Predicates

The where keyword provides the following constructs that allow us to craft most any filtering logic.

Equality (_eq)

query {
  AlbumsMany(where: {title: {_eq: "Cool Album"}}) {
    id
  }
}

Inequality (_neq)

query {
  AlbumsMany(where: {title: {_neq: "Cool Album"}}) {
    id
  }
}

Greater than (_gt)

query {
  AlbumsMany(where: {releasedDate: {_gt: "1970-01-01"}}) {
    id
  }
}

At least (_gte)

A.K.A. greater than or equal to

query {
  AlbumsMany(where: {releasedDate: {_gte: "1970-01-01"}}) {
    id
  }
}

Less than (_lt)

query {
  AlbumsMany(where: {releasedDate: {_lt: "1970-01-01"}}) {
    id
  }
}

At most (_lte)

A.K.A. less than or equal to

query {
  AlbumsMany(where: {releasedDate: {_lte: "1970-01-01"}}) {
    id
  }
}

List has (_in)

query {
  AlbumsMany(where: {title: {_in: ["Fun", "Exciting"]}}) {
    id
  }
}

List has not (_nin)

query {
  AlbumsMany(where: {title: {_nin: ["Sad", "Glum"]}}) {
    id
  }
}

Is empty (_is_null)

query {
  AlbumsMany(
    where: {
      title: { _is_null: true}
      # Pass False for "is not null"
      # title: { _is_null: false}
    }
  ) {
    id
  }
}

String contains (_like)

query {
  AlbumsMany(where: {title: {_like: "Love"}}) {
    id
  }
}

_like is a case-sensitive search. To perform a case-insensitive search, use _ilike.

Logical operands

Combining predicates via AND and OR is also possible via _and & _or, which themselves are predicates, and thus can be nested to produce complex filtering.

Additionally, _not can be used to negate predicates.

Conjunction (_and)

The _and keyword can be used to join a list of predicates with a logical AND operation.

query {
  AlbumsMany(
    where: {
      _and: [
        {title: {_like: "Love"}}
        {releasedDate: {_gte: "1970-01-01"}}
      ]
      # Equivalent, less verbose/explicit:
      # {title: {_like: "Love"}, releasedDate: {_gte: "1970-01-01"}}
    }
  ) {
    id
  }
}

Disjunction (_or)

The _or keyword can be used to join a list of predicates with a logical OR operation.

query {
  AlbumsMany(
    where: {
      _or: [
        {title: {_like: "Love"}},
        {releasedDate: {_gte: "1970-01-01"}}
      ]
    }
  ) {
    id
  }
}

Negation (_not)

The _not keyword can be used to negate the value of a predicate.

query {
  AlbumsMany(
    where: {
      _not: {
        _or: [
          {title: {_like: "Love"}},
          {releasedDate: {_gte: "1970-01-01"}}
        ]
      }
    }
  ) {
    id
  }
}

Here we’re geting all Albums that released before 1970 that don’t contain Love in the title.

Nested relations

Queries can filter on arbitrarily nested relations. For example, we can ask our GraphQL endpoint to give us all Albums that belong to Artists that belong to a set of Labels:

query AlbumsOnLabels($labelIds: [Int!]) {
  AlbumsMany(
    where: {
      artist: {label: {id: {_in: $labelIds}}}
      # Also works:
      # artist: {labelId: {_in: $labelIds}}
    }
  ) {
    id
  }
}

Sort order

Use the orderBy parameter to sort results.

query {
  AlbumsMany(
    orderBy: [
      {title: asc},
      {releasedDate: desc}
    ]
  ) {
    id
  }
}

Pagination

Using limit and offset together allows for pagination of results.

limit

Use the limit parameter to specifiy the number of desired results. The default value is 50.

query {
  AlbumsMany(
    orderBy: [{title: asc}, {releasedDate: desc}]
    limit: 100
  ) {
    id
  }
}

offset

Use the offset parameter to specifiy the number of results to skip. The default value is 0.

query {
  AlbumsMany(
    orderBy: [{title: asc}, {releasedDate: desc}]
    limit: 100
    offset: 100
  ) {
    id
  }
}

Distinct

Use the distinct parameter to return only distinct rows. This can be used to avoid duplicate objects when we’re querying across relations with e.g. _is_null.

Let’s query for all Label objects that have signed at least one Artist, and for each label, return the top 3 artists ordered alphabetically by name:

query {
  LabelsMany(
    where: {artists: {id: {_is_null: false}}}
  ) {
    id
    artists(limit: 3 orderBy: {name: asc}) {
      id
      name
    }
  }
}

If one of our labels has many artists, the resulting LabelsMany list will unfortunately contain a duplicate label item for each artist.

The distinct keyword can help.

query {
  LabelsMany(
    where: {artists: {id: {_is_null: false}}}
    distinct: true
  ) {
    id
    artists(limit: 3 orderBy: {name: asc}) {
      id
      name
    }
  }
}

This will ensure our result contains no duplicate label items, and our result will look like we expect.

Filtering on the result-set

It is also possible to filter via where on to-many relations in the resultset.

This is useful when we want to e.g. get the 3 most recent albums for each artist signed by the Parlophone label.

query {
  ArtistMany(where: {label: {name: {_eq: "Parlophone"}}}) {
    id
    name
    albums(orderBy: {releasedDate: desc} limit: 3) {
      id
      title
    }
  }
}

Validation

Note

Because validation is heavily use case dependent, DjraphQL simply exposes hooks that enable the library user to easily validate input and return errors if necessary.

How validation is performed is left up to the user.

The generated schema provides a validationErrors field on mutation results that can be used to provide feedback to the API consumer about invalid mutation input.

The type of validationError field is a list of dictionary objects with two fields, path and error. The path field indicates the path on the input data that the error applies to.

The path field is handy when, for example, we’re inserting a Label and a nested list of its Artists, and our validation returns errors for the first Artist in the list. The value of path will be ['artists', 0], allowing us to know where exactly the error happened.

Validation errors

To populate the validationErrors field, the Entity class’s validator must be set to an object that has a single method, validate, which returns a two-item tuple (is_valid, errors).

Let’s look at an example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from djraphql import Entity, AllModelFields, C, R
from models import Label

class LabelValidator:
    def validate(self, validatable_input, **kwargs):
        errors = {}
        value = validatable_input.to_value()

        # Validate name
        if not value["name"]:
            errors["name"] = ["Name cannot be empty."]

        # Validate established_year
        if value["established_year"] < 1700:
            errors["established_year"] = ["Label is too old!"]

        is_valid = not bool(errors)
        return (is_valid, errors)

class LabelEntity(Entity):
    class Meta:
        model = Label
        fields = (AllModelFields(),)

    access_permissions = (C, R)
    validator = LabelValidator()

Mostly straight forward, except for the validatable_input argument. This argument is meant to provide a more convenient view of the input data.

Let’s test our validation in the shell.

python manage.py shell

Once in the shell, execute the following commands:

from graphql_api.entities import schema
schema.execute("mutation InsertLabel($name: String!) { insertLabel(data: { name: $name, establishedYear: 1699 }) { success validationErrors result { id } } }", variables={"name": ""}).data
# OrderedDict([('insertLabel', {'success': False, 'validationErrors': [{'path': [], 'errors': {'name': ['Name cannot be empty.'], 'established_year': ['Label is too old!']}}], 'result': None})])

The response indicates that we correctly failed validation. Our validationErrors list contains a single entry. Note that the path field is an empty list because we encountered errors at the root of our input graph.

The validatable_input parameter

Rather than the raw input data, which can be overly nested and contain things like tag or updateStrategy, it’s often much more ergonomic to deal with an object that mirrors your model structure.

To obtain such an object, we can simply call validatable_input.to_value(). This will give us a no-frills object that allows us to perform intuitive validation without having to step through items lists, for example.

Another convenience offered by this parameter is dealing with camel- vs. snake-casing.

If your Graphene schema has auto_camel_case=True (which is the default), then you may find it unexpected that the input is provided to us in a snake-cased format.

Indeed, in the above example, you’ll see that we’re passing establishedYear in the GraphQL mutation, but the validator receives established_year. Consequently, our validationErrors list contains snake-cased keys as well.

To get around this, we can pass camel_case_keys=True to the to_value method which will change the object keys to camel-case, bringing consistency to our validation logic.

# In our .validate() method
value = validatable_input.to_value(camel_case_keys=True)
...
# Now we can use camel-casing everywhere
if value["establishedYear"] < 1700:
    errors["establishedYear"] = ["Label is too old!"]

This will also ensure the validationErrors list has camel-case keys.

schema.execute("mutation InsertLabel($name: String!) { insertLabel(data: { name: $name, establishedYear: 1699 }) { success validationErrors result { id } } }", variables={"name": ""}).data
# OrderedDict([('insertLabel', {'success': False, 'validationErrors': [{'path': [], 'errors': {'name': ['Name cannot be empty.'], 'establishedYear': ['Label is too old!']}}], 'result': None})])

Enums

DjraphQL generates GraphQL Enum types for model fields that have choices.