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 theAlbum
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-clauseorderBy: [OrderByAlbum]
can define how the list is orderedlimit: 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 withlimit
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 theAlbum
being fetchedtag: String
can be used to map inserted objects back to their tags viapksToTags
- 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 Artist
s 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
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 Artist
s 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 apk
and inserts items without apk
.
replace
does the same thing asmerge
, but additionally deletes existing items that aren’t represented (by theirpk
) in theitems
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 Album
s that belong to Artist
s that
belong to a set of Label
s:
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 Artist
s, 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
.