GraphQL
GraphQL implementation
GraphQL servers are often based on a GraphQL schema or a set of models defined in a GraphQL package such as Graphene. Since we already have our models defined in Pydantic, we start from our StateModels and convert them into a GraphQL schema.
This is done by converting the Pydantic models into Graphene models, since Graphene have dealt with the issue of recursive relationships between models. We may be able to remove Graphene from the process if we implement our own solution to that problem.
The conversion to Graphene models is done dynamically when the app is started.
This means we don't have to write the GraphQL models or schema ourselves,
and it is kept up to date with any changes we have made to the Constelite
data model. The conversion is done using the
constelite.graphql.schema.GraphQLSchemaManager
class, which is
also used to supply the schema and data loaders when a GraphQL query is run on
a store.
Since we are creating Graphene models, reading the Graphene docs will help to explain the main concepts.
For the examples below, I'll use this set of StateModels:
from typing import Optional
from constelite.models import StateModel, Association
class Creature(StateModel):
name: Optional[str]
class Human(Creature):
age: Optional[int]
class Cat(Creature):
owner: Optional[Association[Human]]
Conversion to Graphene Models
The StateModels are converted to Graphene models. The field types from the StateModels are converted to the equivalent GraphQL types.
The data we are fetching through Constelite are actually returned as Ref
models, which have a record and a state. We
therefore mirror this structure with our Graphene models. Where we use generics
with Refs to produce Ref[Cat]
and Ref[Human]
, we instead create separate
Graphene models.
We have a Graphene equivalent of the Record and StoreModels defined in
constelite.graphql.schema
:
import graphene
class StoreModelGQL(graphene.ObjectType):
uid = graphene.String()
name = graphene.String()
class RecordGQL(graphene.ObjectType):
uid = graphene.String()
store = graphene.Field(StoreModelGQL)
The rest of the models are dynamically generated. For the StateModels above, classes like the following will be dynamically generated:
import graphene
class CreatureGQLstate(graphene.ObjectType): # Equivalent of Creature
model_name = graphene.String()
name = graphene.String()
class CreatureGQL(graphene.ObjectType): # Equivalent of Ref[Creature]
model_name = graphene.String()
guid = graphene.String()
record = graphene.Field(RecordGQL)
state_model_name = graphene.String()
state = graphene.Field(CreatureGQLstate)
def resolve_state(parent, info):
state = parent.state
if state is None:
context = info.context
dataloader = context.get('dataloaders').get("Creature")
loaded_ref = await dataloader.load(parent.uid)
state = loaded_ref.state
return state
class HumanGQLstate(graphene.ObjectType):
model_name = graphene.String()
name = graphene.String()
age = graphene.Int()
class HumanGQL(graphene.ObjectType):
model_name = graphene.String()
guid = graphene.String()
record = graphene.Field(RecordGQL)
state_model_name = graphene.String()
state = graphene.Field(HumanGQLstate)
def resolve_state(parent, info):
state = parent.state
if state is None:
context = info.context
dataloader = context.get('dataloaders').get("Human")
loaded_ref = await dataloader.load(parent.uid)
state = loaded_ref.state
return state
class CatGQLstate(graphene.ObjectType):
model_name = graphene.String()
name = graphene.String()
age = graphene.Int()
owner = graphene.List(HumanGQL)
class CatGQL(graphene.ObjectType):
model_name = graphene.String()
guid = graphene.String()
record = graphene.Field(RecordGQL)
state_model_name = graphene.String()
state = graphene.Field(CatGQLstate)
def resolve_state(parent, info):
state = parent.state
if state is None:
context = info.context
dataloader = context.get('dataloaders').get("Cat")
loaded_ref = await dataloader.load(parent.uid)
state = loaded_ref.state
return state
Field types from Pydantic models are converted to GraphQL types using the
constelite.graphql.field_type_map.convert_to_graphql_type
. Any fields that
can't be converted by this function is excluded from the GraphQL schema and
we won't be able to fetch them using the GraphQL queries.
The conversion of relationship fields to graphene.List(RelatedModelGQL)
can
be complicated if the RelatedModelGQL
hasn't yet been created. And especially
complicated if RelatedModelGQL
relates (directly or indirectly) back to the
original model (e.g. if humans also had a relationship to the cat they own).
In these cases, we create a function that acts as a placeholder,
and Graphene resolves the models correctly when the full schema is created.
Main GraphQL query class
The GraphQL schema is defined by a Query class. This defines all the queries we can run. We have three models, Creature, Human, and Cat, and want to be able to query our stores for each model type. We define each query as a field of the Query model and a resolver method. The model fields also define the arguments we can use in the resolver function. We currently use the field names, uids and guids as the arguments.
import graphene
class Query(graphene.ObjectType):
creatures = graphene.List(
CreatureGQL,
uid=graphene.String(),
uids=graphene.List(graphene.String),
guid=graphene.String(),
guids=graphene.List(graphene.String),
name=graphene.String()
)
humans = graphene.List(
HumanGQL,
uid=graphene.String(),
uids=graphene.List(graphene.String),
guid=graphene.String(),
guids=graphene.List(graphene.String),
name=graphene.String(),
age=graphene.Int()
)
cats = graphene.List(
CatGQL,
uid=graphene.String(),
uids=graphene.List(graphene.String),
guid=graphene.String(),
guids=graphene.List(graphene.String),
name=graphene.String(),
age=graphene.String(),
owner=graphene.String() # Argument is the UID of the owner
)
# the Resolver methods takes the GraphQL context (root, info) as well as
# any additional arguments for the Field and returns data for the query Response
def resolve_creatures(root, info, uid, uids, guid, guids, name):
# Query the store using any arguments as filters
# See the next section for an explanation of these functions
...
def resolve_humans(root, info, uid, uids, guid, guids, name, age):
...
def resolve_cats(root, info, uid, uids, guid, guids, name, age, owner):
...
Once the Query class is defined, we use it to create the GraphQL schema.
This schema is then used to execute the queries.A traditional GraphQL schema can also be generated from our Graphene schema.
print(schema)
:
type Query {
statemodels(guid: String, uid: String, uids: [String], guids: [String], model_name: String): [StateModelGQL]
creatures(guid: String, uid: String, uids: [String], guids: [String], model_name: String, name: String): [CreatureGQL]
humans(guid: String, uid: String, uids: [String], guids: [String], model_name: String, name: String, age: int): [HumanGQL]
cats(guid: String, uid: String, uids: [String], guids: [String], model_name: String, name: String, age: int, owner: String): [CatGQL]
}
type RecordGQL {
uid: String
store: StoreModelGQL
}
type StoreModelGQL {
uid: String
name: String
}
type CreatureGQL {
model_name: String
guid: String
record: RecordGQL
state_model_name: String
state: CreatureGQLstate
}
type CreatureGQLstate {
model_name: String
name: String
}
type HumanGQL {
model_name: String
guid: String
record: RecordGQL
state_model_name: String
state: HumanGQLstate
}
type HumanGQLstate {
model_name: String
name: String
age: Int
}
type CatGQL {
model_name: String
guid: String
record: RecordGQL
state_model_name: String
state: CatGQLstate
}
type CatGQLstate {
model_name: String
name: String
age: Int
owner: [HumanGQL]
}
Resolver functions
To execute the queries, GraphQL needs to know how to use the arguments to the resolvers to fetch data from whichever data source it is that we want to use.
There are two types of resolvers that we define:
- the resolver functions in the Query class, e.g.
Query.resolve_cats
- the resolver functions for fetching states, e.g.
CatGQL.resolve_state
The resolver functions in the Query class are the starting points of GraphQL
queries. We define the store and dataloaders they should use in the
store.execute_graphql
function, and these are retrieved in the resolver function
from info.context
. If the resolvers are passed uids or guids as arguemnts,
the resolver function runs store.get
for each id, otherwise it
runs store.query
using any other resolver arguments as filters.
The resolve_state
functions are used when we need to fetch a value from a
related model and therefore need to fetch the state of the related Ref. You can
see examples in the Conversion to Graphene Models section above.
The resolve_state
functions use the dataloaders. Each dataloader
takes a list of UIDs as arguments and runs store.get
for each uid. There
is one dataloader defined for each Constelite StateModel class, and each runs
only once after all Refs for that class are collected.
E.g. if we have seven cats, and they are related
to owners with uids 1, 1, 2, 1, 2, 1, and 2, the dataloader for Humans
will run store.get
only twice - once for uid 1 and once for uid 2.
Creating GraphQL queries
GraphQL queries can be written as strings like this:
We have to use these strings when using thestore/graphql
endpoint.
However, if we are using the GraphQL queries to fetch Constelite models from the
store/graphql_models
endpoint, we also need to make sure the fields required
for the Ref
model are included:
{
cats (name: "Snowball") {
guid
model_name
state_model_name
record {
uid
store {
uid
}
}
state {
name
age
owner {
guid
model_name
state_model_name
record {
uid
store {
uid
}
}
state {
model_name
name
}
}
}
}
To make this easier the GraphQLModelQuery
can also use the
graphql_query package to generate
the GraphQL strings for us. The additional fields required for the definition
of the Ref
model are automatically added.
import graphql_query
from constelite.graphql.utils import GraphQLModelQuery
GraphQLModelQuery(
fields=[
'name',
'age',
graphql_query.Field(name="owner", fields=['name'])
],
arguments={'name': '"Snowball"'},
state_model_name='Cat'
)
GraphQLModelQuery
class and then
used to execute the graphql query.
Overwriting GraphQL methods
Some databases can work with GraphQL queries directly
(or with the use of a plugin), and we may not need to
use the schema and resolvers that we have defined above. In those cases, we
can overwrite the execute_graphql
function to run GraphQL queries using more
efficient methods.