Graphene Graphql - how to chain mutations
Asked Answered
P

2

5

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.

Pallette answered 21/4, 2020 at 9:25 Comment(8)
you have root mutations then order is guaranteed ... const updateBook = (book, authorId) => { gives a hint how to get id of the same args (used to creation)Viticulture
@Viticulture do you mean getting the uuid in a previous query and using it in the mutations is ok, and because order is guaranteed I get the expected result ? (One of my main concerns is to know if it is fine to use a uui previously queried from the server, another is to know if Graphene (backend) offers an alternative)Pallette
createUser(username="new user", password="secret"){.. ... updateGroup(gid="group id", username="new user", password="secret"){ ... second resolver can find inserted id by find using (username, password) ... you don't need to expose/use internals (uid) at all ... in this placeViticulture
@Viticulture indeed some data will have intrinsic identifiers (User with email, Book with ISBN...), but other data might not have such functional unique keys. In an application, I have a Degree entity for example, and I don't think it has a field, expect an ID, that guarantees to retrieve a specific instance... maybe I should have found a better use case :)Pallette
nested data for mutation? insert inside insert/update mutation ... https://mcmap.net/q/2031302/-graphql-how-retrieve-id-of-previous-mutation-during-query-of-multiple-mutationsViticulture
I actually had* a first version with mutations including nested data to mutate related data. I abandoned that because it caused security issues (permission checks normally done on (root-)mutations would either be duplicated or not done). Maybe it was just bad coding of mine, I'll think about it again, thanks @ViticulturePallette
PS: the solution to my security issue were to use nested mutations, so the mutation of related data would go through the process of resolving, thus permission checks. And... that led me to the question of this post :DPallette
https://mcmap.net/q/460352/-how-to-chain-two-graphql-queries-in-sequence-using-apollo-client ... and similar ... if you need to use result, use it in a separate query .... however you can try/check if you can pass data by context between mutations/resolvers .... duplicated permision checks ... resuse them (functions/annotations/etc.)?Viticulture
P
3

So, I created the graphene-chain-mutation Python package to work with Graphene-python and allow to reference the results of node-like mutations in edge-like mutations in the same query. I'll just paste the usage section below:

5 steps (See the test/fake.py module for an executable example).

  1. Install the package (requires graphene)
pip install graphene-chain-mutation
  1. Write node-like mutations by inheriting ShareResult before graphene.Muation:
 import graphene
 from graphene_chain_mutation import ShareResult
 from .types import ParentType, ParentInput, ChildType, ChildInput

 class CreateParent(ShareResult, graphene.Mutation, ParentType):
     class Arguments:
         data = ParentInput()

     @staticmethod
     def mutate(_: None, __: graphene.ResolveInfo,
                data: ParentInput = None) -> 'CreateParent':
         return CreateParent(**data.__dict__)

 class CreateChild(ShareResult, graphene.Mutation, ChildType):
     class Arguments:
         data = ChildInput()

     @staticmethod
     def mutate(_: None, __: graphene.ResolveInfo,
                data: ChildInput = None) -> 'CreateChild':
         return CreateChild(**data.__dict__)
  1. Create edge-like mutations by inheriting either ParentChildEdgeMutation (for FK relationships) or SiblingEdgeMutation (for m2m relationships). Specify the type of their input nodes and implement the set_link method:
 import graphene
 from graphene_chain_mutation import ParentChildEdgeMutation, SiblingEdgeMutation
 from .types import ParentType, ChildType
 from .fake_models import FakeChildDB

 class SetParent(ParentChildEdgeMutation):

     parent_type = ParentType
     child_type = ChildType

     @classmethod
     def set_link(cls, parent: ParentType, child: ChildType):
         FakeChildDB[child.pk].parent = parent.pk

 class AddSibling(SiblingEdgeMutation):

     node1_type = ChildType
     node2_type = ChildType

     @classmethod
     def set_link(cls, node1: ChildType, node2: ChildType):
         FakeChildDB[node1.pk].siblings.append(node2.pk)
         FakeChildDB[node2.pk].siblings.append(node1.pk)
  1. Create your schema as usual
 class Query(graphene.ObjectType):
     parent = graphene.Field(ParentType, pk=graphene.Int())
     parents = graphene.List(ParentType)
     child = graphene.Field(ChildType, pk=graphene.Int())
     children = graphene.List(ChildType)

 class Mutation(graphene.ObjectType):
     create_parent = CreateParent.Field()
     create_child = CreateChild.Field()
     set_parent = SetParent.Field()
     add_sibling = AddSibling.Field()

 schema = graphene.Schema(query=Query, mutation=Mutation)
  1. Specify the ShareResultMiddleware middleware while executing a query:
 result = schema.execute(
     GRAPHQL_MUTATION
     ,variables = VARIABLES
     ,middleware=[ShareResultMiddleware()]
 )

Now GRAPHQL_MUTATION can be a query where edge-like mutation reference the results of node-like mutations:

GRAPHQL_MUTATION = """
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 }
}
"""

VARIABLES = dict(
    parent = dict(
        name = "Emilie"
    )
    ,child1 = dict(
        name = "John"
    )
    ,child2 = dict(
        name = "Julie"
    )
)
Pallette answered 26/4, 2020 at 11:38 Comment(5)
still only for root level mutations (guaranteed order), not deeper/nested (not preserving order) ?Viticulture
@Viticulture yes, only for root mutations. The idea is to use only root mutations, as only them are guaranteed to be sequential, and yet being able to use the result of one such root mutation in another root mutation to mutate "edges", links between the entities. -Edit: the initial question actually "how to chain mutations" (it wasn't really about nesting)Pallette
@Viticulture I wasn't sure I understood your question correctly, so I just tested referencing the result of a root mutation in nested mutation (using a resolver) and it works. I updated the Readme on Github and Pypi.Pallette
question was about non-root, between two sibbling nested mutations - use case from 'bad practice' linkViticulture
@Viticulture I didn't tested that, but I guess you can, under the condition that you use resolvers and let them have the shared_results parameter. The initial issue remains though, nested operations have no guarantee of order. One would need to add the results of the nested operations in the sharing dict. A custom decorator can help automate that.Pallette
P
6

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.

Pallette answered 22/4, 2020 at 11:57 Comment(1)
@Viticulture was the first to suggest injecting mutation results in the context. I took inspiration from this to think a node+edge proposalPallette
P
3

So, I created the graphene-chain-mutation Python package to work with Graphene-python and allow to reference the results of node-like mutations in edge-like mutations in the same query. I'll just paste the usage section below:

5 steps (See the test/fake.py module for an executable example).

  1. Install the package (requires graphene)
pip install graphene-chain-mutation
  1. Write node-like mutations by inheriting ShareResult before graphene.Muation:
 import graphene
 from graphene_chain_mutation import ShareResult
 from .types import ParentType, ParentInput, ChildType, ChildInput

 class CreateParent(ShareResult, graphene.Mutation, ParentType):
     class Arguments:
         data = ParentInput()

     @staticmethod
     def mutate(_: None, __: graphene.ResolveInfo,
                data: ParentInput = None) -> 'CreateParent':
         return CreateParent(**data.__dict__)

 class CreateChild(ShareResult, graphene.Mutation, ChildType):
     class Arguments:
         data = ChildInput()

     @staticmethod
     def mutate(_: None, __: graphene.ResolveInfo,
                data: ChildInput = None) -> 'CreateChild':
         return CreateChild(**data.__dict__)
  1. Create edge-like mutations by inheriting either ParentChildEdgeMutation (for FK relationships) or SiblingEdgeMutation (for m2m relationships). Specify the type of their input nodes and implement the set_link method:
 import graphene
 from graphene_chain_mutation import ParentChildEdgeMutation, SiblingEdgeMutation
 from .types import ParentType, ChildType
 from .fake_models import FakeChildDB

 class SetParent(ParentChildEdgeMutation):

     parent_type = ParentType
     child_type = ChildType

     @classmethod
     def set_link(cls, parent: ParentType, child: ChildType):
         FakeChildDB[child.pk].parent = parent.pk

 class AddSibling(SiblingEdgeMutation):

     node1_type = ChildType
     node2_type = ChildType

     @classmethod
     def set_link(cls, node1: ChildType, node2: ChildType):
         FakeChildDB[node1.pk].siblings.append(node2.pk)
         FakeChildDB[node2.pk].siblings.append(node1.pk)
  1. Create your schema as usual
 class Query(graphene.ObjectType):
     parent = graphene.Field(ParentType, pk=graphene.Int())
     parents = graphene.List(ParentType)
     child = graphene.Field(ChildType, pk=graphene.Int())
     children = graphene.List(ChildType)

 class Mutation(graphene.ObjectType):
     create_parent = CreateParent.Field()
     create_child = CreateChild.Field()
     set_parent = SetParent.Field()
     add_sibling = AddSibling.Field()

 schema = graphene.Schema(query=Query, mutation=Mutation)
  1. Specify the ShareResultMiddleware middleware while executing a query:
 result = schema.execute(
     GRAPHQL_MUTATION
     ,variables = VARIABLES
     ,middleware=[ShareResultMiddleware()]
 )

Now GRAPHQL_MUTATION can be a query where edge-like mutation reference the results of node-like mutations:

GRAPHQL_MUTATION = """
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 }
}
"""

VARIABLES = dict(
    parent = dict(
        name = "Emilie"
    )
    ,child1 = dict(
        name = "John"
    )
    ,child2 = dict(
        name = "Julie"
    )
)
Pallette answered 26/4, 2020 at 11:38 Comment(5)
still only for root level mutations (guaranteed order), not deeper/nested (not preserving order) ?Viticulture
@Viticulture yes, only for root mutations. The idea is to use only root mutations, as only them are guaranteed to be sequential, and yet being able to use the result of one such root mutation in another root mutation to mutate "edges", links between the entities. -Edit: the initial question actually "how to chain mutations" (it wasn't really about nesting)Pallette
@Viticulture I wasn't sure I understood your question correctly, so I just tested referencing the result of a root mutation in nested mutation (using a resolver) and it works. I updated the Readme on Github and Pypi.Pallette
question was about non-root, between two sibbling nested mutations - use case from 'bad practice' linkViticulture
@Viticulture I didn't tested that, but I guess you can, under the condition that you use resolvers and let them have the shared_results parameter. The initial issue remains though, nested operations have no guarantee of order. One would need to add the results of the nested operations in the sharing dict. A custom decorator can help automate that.Pallette

© 2022 - 2024 — McMap. All rights reserved.