5

我碰巧向 Graphql API(Python3 + Graphene)发送了 2 个单独的请求,以便:

  1. 创建一个对象
  2. 更新另一个对象,使其与创建的对象相关。

我感觉到这可能不在 Graphql 的“精神”中,所以我搜索并阅读了有关嵌套迁移的信息。不幸的是,我还发现这是一种不好的做法,因为嵌套迁移不是顺序的,并且由于竞争条件,它可能会导致客户端难以调试问题。

我正在尝试使用顺序根突变来实现考虑嵌套迁移的用例。请允许我向您展示我想象的一个用例和一个简单的解决方案(但可能不是好的做法)。对不起,很长的帖子来了。

让我们想象一下,我有用户和组实体,我希望从客户端表单更新组,不仅可以添加用户,还可以在用户不存在时创建要添加到组中的用户。用户有名为 uid (user id) 和 groups gid (groupd id) 的 id,只是为了突出区别。所以使用根突变,我想像做一个查询:

mutation {
    createUser(uid: "b53a20f1b81b439", username: "new user", password: "secret"){
        uid
        username
    }

    updateGroup(gid: "group id", userIds: ["b53a20f1b81b439", ...]){
        gid
        name
    }
}

您注意到我在createUser突变的输入中提供了用户 ID。我的问题是要进行updateGroup突变,我需要新创建用户的 ID。我不知道如何在 mutate 方法解析中的石墨烯中获得它updateGroup,所以我想象在加载客户端表单数据时从 API 查询 UUID。因此,在发送上述突变之前,在我的客户端初始加载时,我会执行以下操作:

query {
    uuid

    group (gid: "group id") {
        gid
        name
    }
}

然后,我将在突变请求中使用此查询响应中的 uuid(该值将是b53a20f1b81b439,如上面的第一个 scriptlet 中所示)。

你怎么看这个过程?有更好的方法吗?Pythonuuid.uuid4可以安全地实现这个吗?

提前致谢。

- - - 编辑

根据评论中的讨论,我应该提到上面的用例仅用于说明。实际上,用户实体可能具有固有的唯一键(电子邮件、用户名),其他实体也可能具有(ISBN for Book...)。我正在寻找一个通用案例解决方案,包括可能不显示这种自然唯一键的实体。

4

2 回答 2

5

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.

于 2020-04-22T11:57:36.390 回答
2

因此,我创建了graphene-chain-mutation Python 包来与Graphene-python一起使用,并允许在同一个查询中引用类似节点的突变结果和类似边缘的突变。我将在下面粘贴使用部分:

5 个步骤(有关可执行示例,请参见test/fake.py 模块)。

  1. 安装包(需要石墨烯
pip install graphene-chain-mutation
  1. 通过继承before编写类似节点的突变:ShareResult 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. 通过继承(对于 FK 关系)或(对于 m2m 关系)来创建类似边缘的突变。指定其输入节点的类型并实现方法:ParentChildEdgeMutationSiblingEdgeMutationset_link
 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. 像往常一样创建您的架构
 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. ShareResultMiddleware在执行查询时指定中间件:
 result = schema.execute(
     GRAPHQL_MUTATION
     ,variables = VARIABLES
     ,middleware=[ShareResultMiddleware()]
 )

现在GRAPHQL_MUTATION可以是一个查询,其中类似边缘的突变引用了类似节点的突变的结果:

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"
    )
)
于 2020-04-26T11:38:56.897 回答