GraphQL: Can you mutate the results of a query?

Viewed 2067

In writing this question I realised that there is something very specific I want to be able to do in GraphQL, and I can't see a good way of implementing it. The idea is this:

One of the nice things about GraphQL is that it allows you to make flexible queries. For example, if I want to find all the comments on all the posts of each user in a particular forum then I can make the query

query{
  findForum(id:7){
    users{
      posts{
        comments{
          content
        }
      }
    }
  }
} 

which is great. Often, you want to collect data with the intention of mutating it. So in this case, maybe I don't want to fetch all of those comments, and instead I want to delete them. A naive suggestion is to implement a deleteComment field on the comment type, which mutates the object it is called on. This is bad because the request is tagged as a query, so it should not mutate data.

Since we're mutating data, we should definitely tag this as a mutation. But then we lose the ability to make the query we wanted to make, because findForum is a query field, not a mutation field. A way around this might be to redefine all the query fields you need inside the mutation type. This is obviously not a good idea, because you repeat a lot of code, and also make the functionality for query a strict subset of that of mutation.

Now, what I regard as the 'conventional' solution is to make a mutation field which does this job and nothing else. So you define a mutation field deleteAllUserPostCommentsByForum which takes an argument, and implement it in the obvious way. But now you've lost the flexibility! If you decide instead that you want to find the user explicitly, and delete all their posts, or if you only want to delete some of their posts, you need a whole new mutation field. This feels like precisely the sort of thing I though GraphQL was useful for when compared to REST.

So, is there a good way to avoid these problems simultaneously?

2 Answers

Under the hood, the only real difference between queries and mutations is that if a single operation includes multiple mutations, they are resolved sequentially (one at a time) rather than concurrently. Queries, and all other fields are resolved concurrently. That means for an operation like this:

mutation myOperation {
  editComment(id: 1, body: "Hello!")
  deleteComment(id: 1)
}

The editComment mutation will resolve before the deleteComment mutation. If these operations were queries, they would both be ran at the same time. Likewise, consider if you have a mutation that returns an object, like this:

mutation myOperation {
  deleteComment(id: 1) {
    id
    name
  }
}

In this case, the id and name fields are also resolved at the same time (because, even though they are returned as part of a mutation, the fields themselves are not mutations).

This difference in behavior between queries and mutations highlights why by convention we define a single mutation per operation and avoid "nesting" mutations like your question suggests.

The key to making your mutations more flexible lies in how you pass in inputs to your mutation subsequently how you handle those inputs inside your resolver. Instead of making a deleteAllUserPostCommentsByForum mutation, just make a deleteComments mutation that accepts a more robust InputType, for example:

input DeleteCommentsInput {
  forumId: ID
  userId: ID
}

Your resolver then just needs to handle whatever combination of input fields that may be passed in. If you're using a db, this sort of input very easily translates to a WHERE clause. If you realize you need additional functionality, for example deleting comments before or after a certain date, you can then add those fields to your Input Type and modify your resolver accordingly -- no need to create a new mutation.

You can actually handle creates and edits similarly and keep things a little DRY-er. For example, your schema could look like this:

type Mutation {
  createOrUpdateComment(comment: CommentInput)
}

input CommentInput {
  id: ID
  userId: ID
  body: String
}

Your resolver can then check whether an ID was included -- if so, then it treats the operation as an update, otherwise it treats the operation as an insert. Of course, using non-nulls in this case can get tricky (userId might be needed for a create but not an update) so there's something to be said for having separate Input Types for each kind of operation. However, hopefully this still illustrates how you can leverage input types to make your mutations more flexible.

IMHO you lose many indirect aspects.

Trying to create 'flexible' query can result in highly unoptimized server actions.

Queries are resolved structurally, level by level, which may result in processing to many unnecessary data (high memory usage). It can't be optimized on lower layers (f.e. sql server) - it will result in a naive implementation (processing) like many 'manually fired' SQL queries vs. one more complex query with conditions.

In this case f.e. server doesn't need all users at all while user's post/comment usually contain user_id (and forum/thread/post ids) field - it can be processed directly on one table (with joined posts). You don't need the whole structure to affect only some of the elements.

The real power and flexibility of graphQL are placed on the resolvers.

Notice that deleting all or only some comments can be completely different implemented. Resolver can choose a better way (by parameters as Daniel wrote) but for simplicity (readability of the API) it would be better to have a separate mutations.

Related