Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Graphene Graphql - how to chain mutations

I happened to send 2 separated requests to a Graphql API (Python3 + Graphene) in order to:

  1. Create an object
  2. Update another object so that it relates to the created one.

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.

like image 397
Josuah Aron Avatar asked Apr 21 '20 09:04

Josuah Aron


People also ask

Are GraphQL mutations Atomic?

While GraphQL allows the client to freely shape query and response, mutations (create, update or delete operations) are by design atomic.

Can GraphQL mutation return nothing?

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.


1 Answers

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.


EDIT 1: sharing results

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.


EDIT 2: nesting creations

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.

like image 60
Josuah Aron Avatar answered Sep 27 '22 18:09

Josuah Aron