I happened to send 2 separated requests to a Graphql API (Python3 + Graphene) in order to:
I sensed this might not be in the "spirit" of Graphql, so I searched and read about nested migrations. Unforutnately, I also found that it was bad practice because nested migrations are not sequential and it might lead clients in hard to debug problems due to race conditions.
I'm trying to use sequential root mutations in order to implement the use cases where nested migrations were considered. Allow me to present you a use case and a simple solution (but probably not good practice) I imagined. Sorry for the long post coming.
Let's image I have User and Group entities, and I want, from the client form to update a group, to be able to not only add a user, but also create a user to be added in a group if the user does not exist. The users have ids named uid (user id) and groups gid (groupd id), just to highlight the difference. So using root mutations, I imagine doing a query like:
mutation {
createUser(uid: "b53a20f1b81b439", username: "new user", password: "secret"){
uid
username
}
updateGroup(gid: "group id", userIds: ["b53a20f1b81b439", ...]){
gid
name
}
}
You noticed that I provide the user id in the input of the createUser
mutation. My problem is that to make the updateGroup
mutation, I need the ID of the newly created user. I don't know a way to get that in graphene inside the mutate methods resolving updateGroup
, so I imagined querying a UUID from the API while loading the client form data. So before sending the mutation above, at the initial loading of my client, I would do something like:
query {
uuid
group (gid: "group id") {
gid
name
}
}
Then I would use the uuid from the response of this query in the mutation request (the value would be b53a20f1b81b439
, as in the the first scriptlet above).
What do you think about this process ? Is there a better way to do that ? Is Python uuid.uuid4
safe to implement this ?
Thanks in advance.
----- EDIT
Based on a discussion in the comments, I should mention that the use case above is for illustration only. Indeed, a User entity might have an intrinsic unique key (email, username), as well as other entities might (ISBN for Book...). I'm looking for a general case solution, including for entities that might not exhibit such natural unique keys.
While GraphQL allows the client to freely shape query and response, mutations (create, update or delete operations) are by design atomic.
According to this Github issue you cannot return nothing. You can define a return type which is nullable e.g. But I suggest you return the id of the deleted element, because if you want to work with a cached store you have to update the store when the delete mutation has ran successfully.
There were a number of suggestions in the comments under the initial question. I'll come back to some at the end of this proposal.
I have been thinking about this problem and also the fact that it seems to be a recurring question among developers. I have come to conclude that may we miss something in the way we want to edit our graph, namely edge operations. I think we try to do edges operations with node operations. To illustrate this, a graph creation in a language like dot (Graphviz) may look like:
digraph D {
/* Nodes */
A
B
C
/* Edges */
A -> B
A -> C
A -> D
}
Following this pattern, maybe the graphql mutation in the question should look like:
mutation {
# Nodes
n1: createUser(username: "new user", password: "secret"){
uid
username
}
n2: updateGroup(gid: "group id"){
gid
name
}
# Edges
addUserToGroup(user: "n1", group: "n2"){
status
}
}
The inputs of the "edge operation" addUserToGroup
would be the aliases of the previous nodes in the mutation query.
This would also allow to decorate edge operations with permission checks (permissions to create a relation may differ from permissions on each object).
We can definitely resolve a query like this already. What is less sure is if backend frameworks, Graphene-python in particular, provide mechanisms to allow the implementation of addUserToGroup
(having the previous mutation results in the resolution context). I'm thinking of injecting a dict
of the previous results in the Graphene context. I'll try and complete the answer with technical details if successful.
Maybe there exist way to achieve something like this already, I will also look for that and complete the answer if found.
If it turns out the pattern above is not possible or found bad practice, I think I will stick to 2 separate mutations.
I tested a way of resolving a query like above, using a Graphene-python middleware and a base mutation class to handle sharing the results. I created a one-file python program available on Github to test this. Or play with it on Repl.
The middleware is quite simple and adds a dict as kwarg
parameter to the resolvers:
class ShareResultMiddleware:
shared_results = {}
def resolve(self, next, root, info, **args):
return next(root, info, shared_results=self.shared_results, **args)
The base class is also quite simple and manages the insertion of results in the dictionary:
class SharedResultMutation(graphene.Mutation):
@classmethod
def mutate(cls, root: None, info: graphene.ResolveInfo, shared_results: dict, *args, **kwargs):
result = cls.mutate_and_share_result(root, info, *args, **kwargs)
if root is None:
node = info.path[0]
shared_results[node] = result
return result
@staticmethod
def mutate_and_share_result(*_, **__):
return SharedResultMutation() # override
A node-like mutation that need to comply with the shared result pattern would inherit from SharedResultMutation
in stead of Mutation
and override mutate_and_share_result
instead of mutate
:
class UpsertParent(SharedResultMutation, ParentType):
class Arguments:
data = ParentInput()
@staticmethod
def mutate_and_share_result(root: None, info: graphene.ResolveInfo, data: ParentInput, *___, **____):
return UpsertParent(id=1, name="test") # <-- example
The edge-like mutations need to access the shared_results
dict, so they override mutate
directly:
class AddSibling(SharedResultMutation):
class Arguments:
node1 = graphene.String(required=True)
node2 = graphene.String(required=True)
ok = graphene.Boolean()
@staticmethod
def mutate(root: None, info: graphene.ResolveInfo, shared_results: dict, node1: str, node2: str): # ISSUE: this breaks type awareness
node1_ : ChildType = shared_results.get(node1)
node2_ : ChildType = shared_results.get(node2)
# do stuff
return AddSibling(ok=True)
And that's basically it (the rest is common Graphene boilerplate and test mocks). We can now execute a query like:
mutation ($parent: ParentInput, $child1: ChildInput, $child2: ChildInput) {
n1: upsertParent(data: $parent) {
pk
name
}
n2: upsertChild(data: $child1) {
pk
name
}
n3: upsertChild(data: $child2) {
pk
name
}
e1: setParent(parent: "n1", child: "n2") { ok }
e2: setParent(parent: "n1", child: "n3") { ok }
e3: addSibling(node1: "n2", node2: "n3") { ok }
}
The issue with this is that the edge-like mutation arguments do not satisfy the type awareness that GraphQL promotes: in the GraphQL spirit, node1
and node2
should be typed graphene.Field(ChildType)
, instead of graphene.String()
as in this implementation. EDIT Added basic type checking for edge-like mutation input nodes.
For comparison, I also implemented a nesting pattern where only creations are resolved (it the only case where we cannot have the data in previous query), one-file program available on Github.
It is classic Graphene, except for the mutation UpsertChild
were we add field to solve nested creations and their resolvers:
class UpsertChild(graphene.Mutation, ChildType):
class Arguments:
data = ChildInput()
create_parent = graphene.Field(ParentType, data=graphene.Argument(ParentInput))
create_sibling = graphene.Field(ParentType, data=graphene.Argument(lambda: ChildInput))
@staticmethod
def mutate(_: None, __: graphene.ResolveInfo, data: ChildInput):
return Child(
pk=data.pk
,name=data.name
,parent=FakeParentDB.get(data.parent)
,siblings=[FakeChildDB[pk] for pk in data.siblings or []]
) # <-- example
@staticmethod
def resolve_create_parent(child: Child, __: graphene.ResolveInfo, data: ParentInput):
parent = UpsertParent.mutate(None, __, data)
child.parent = parent.pk
return parent
@staticmethod
def resolve_create_sibling(node1: Child, __: graphene.ResolveInfo, data: 'ChildInput'):
node2 = UpsertChild.mutate(None, __, data)
node1.siblings.append(node2.pk)
node2.siblings.append(node1.pk)
return node2
So the quantity of extra stuff is small compared to to the node+edge pattern. We can now execute a query like:
mutation ($parent: ParentInput, $child1: ChildInput, $child2: ChildInput) {
n1: upsertChild(data: $child1) {
pk
name
siblings { pk name }
parent: createParent(data: $parent) { pk name }
newSibling: createSibling(data: $child2) { pk name }
}
}
However, we can see that, in contrast to what was possible with the node+edge pattern,(shared_result_mutation.py) we cannot set the parent of the new sibling in the same mutation. The obvious reason is that we don't have its data (its pk in particular). The other reason is because order is not guaranteed for nested mutations. So cannot create, for example, a data-less mutation assignParentToSiblings
that would set the parent of all siblings of the current root child, because the nested sibling may be created before the nested parent.
In some practical cases though, we just need to create a new object and and then link it to an exiting object. Nesting can satisfy these use cases.
There was a suggestion in the question's comments to use nested data for mutations. This actually was my first implementation of the feature, and I abandoned it because of security concerns. The permission checks use decorators and look like (I don't really have Book mutations):
class UpsertBook(common.mutations.MutationMixin, graphene.Mutation, types.Book):
class Arguments:
data = types.BookInput()
@staticmethod
@authorize.grant(authorize.admin, authorize.owner, model=models.Book)
def mutate(_, info: ResolveInfo, data: types.BookInput) -> 'UpsertBook':
return UpsertBook(**data) # <-- example
I don't think I should also make this check in another place, inside another mutation with nested data for example. Also, calling this method in another mutation would requires imports between mutation modules, which I don't think is a good idea. I really thought the solution should rely on GraphQL resolution capabilities, that's why I looked into nested mutations, which led me to ask the question of this post in the first place.
Also, I made more tests of the uuid idea from the question (with a unittest Tescase). It turns out that quick successive calls of python uuid.uuid4 can collide, so this option is discarded to me.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With