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
pkis the primary key of theAlbumbeing fetched
- Returns
Albumobject matching the given primary key
*sMany¶
- Args
where: WhereAlbumcan be used to craft a set of conditions in a SQL where-clauseorderBy: [OrderByAlbum]can define how the list is orderedlimit: Intcan define how many items are returned. The default value is 50.offset: Intcan define how many items to skip within the resulting SQL query before returning the list of items. Can be used in conjunction withlimitto 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 theAlbumbeing fetchedtag: Stringcan be used to map inserted objects back to their tags viapksToTags
- Returns
InsertAlbumresult 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 updateddata(AlbumInput!) is an object specifying the updated fields
- Returns
UpdateAlbumresult 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 apkand inserts items without apk.
replacedoes the same thing asmerge, but additionally deletes existing items that aren’t represented (by theirpk) in theitemslist.
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
DeleteAlbumresult 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.