Search code examples
graphqlapolloreact-apollographql-subscriptions

Apollo GraphQL subscription response doesn't handle nested queries


I have the following GraphQL subscription that works fine:

subscription voucherSent($estId: Int!) {
  voucherSent(estId: $estId) {
    id
    name
    usedAt
    sentAt
  }
}

But the following sends an "Cannot read property 'User' of undefined" error

subscription voucherSent($estId: Int!) {
  voucherSent(estId: $estId) {
    id
    name
    usedAt
    sentAt
    owner {
      id
      username
    }
  }
}

Does Apollo GraphQL subscription handle nested queries?

Here is my resolver code:

return models.Voucher.update({
    sentAt: moment().format(),
    usedIn: args.sentTo,
  }, { where: { id: args.id } })
    .then(resp => (
      models.Voucher.findOne({ where: { id: args.id } })
        .then((voucher) => {
          pubsub.publish(VOUCHER_SENT, { voucherSent: voucher, estId: voucher.usedIn });
          return resp;
        })
    ))

Solution

  • Apollo Graphql subscription has very brief documentation about the subscriptions. I think I understand your question and I had the exactly same issue. Based on all the source code reading and testing, I think I know a "not as good solution" to this.

    Let me first explain why your code did not work. Your code did not work is because the user subscribed and the user did the mutation are not the same person. Let me elaborate. I see your resolver function, I assume the resolver is some mutation resolver, and inside that resolver, you do a pubsub. But the problem is, in that resolver, your webserver is dealing with the request that made the mutation. It had no idea who subscribed to the channel and what fields are they subscribed to. so your best bet is to send back all fields of the Voucher model, which is what you did

     models.Voucher.findOne({ where: { id: args.id } })
    

    But it won't work with subscribers who subscribed to nested fields. You can definitely modify your code when broadcasting to

     models.Voucher.include("owner").findOne({ where: { id: args.id } })
     .then(voucher=>pubsub.publish(VOUCHER_SENT, { voucherSent: voucher, estId: voucher.usedIn });
    

    This is like pseudocode, but you get the idea. If you always broadcast the data with the nested fields, then you'll be ok. But it's not dynamic. You will get into trouble if the subscriber subscribe to more nested fields etc.

    If your server is simple, and broadcast static data is good enough. Then you can stop here. The next section is going in detail about how the subscription works.

    First of all, when the client made a query, your resolver will be passed in 4 parameters. For subscription resolver, the first 3 don't really matter, but the last one contains the query, return type, etc. This parameter is called Info. Say you make a subscription

    subscription {
      voucherSent(estId: 1) {
        id
        name
        usedAt
        sentAt
      }
    }
    

    And another regular query:

    query {
      getVoucher(id:1) {
        id
        name
        usedAt
        sentAt
      }
    }
    

    The Info parameter is the same, because it stores the return Type, return Fields, etc. Depend on how you setup your resolvers, you should have some way to manually fetch the result if your query contains nested fields.

    Now, there are two places where you need to write your code. 1. The subscribe resolver. In Apollo's documentation, as an example:

    Subscription: {
      postAdded: {
        // Additional event labels can be passed to asyncIterator creation
        subscribe: () => pubsub.asyncIterator([Channel Name]),
      },
    },
    

    In here, your subscribe is a function where the fourth parameter, (the Info), is crucial for you to know what fields the user was subscribed to. So you need to somehow store it, If you have multiple user subscribe to the same Voucher, but with different fields, storing these is very important. Luckily, the apollo graphql-subscription does that already.

    Your subscription function should be:

    Subscription{
      voucherSent(estid:ID):{
        subscribe: (p,a,c,Info)=>{
            // Return an  asyncIterator object. 
        }
      }
    }
    

    To see why it has to be asyncIterator object, check out the doc here. So it has a great helper, the withFilter function, which will filter the published object. This function takes a function as it's second parameter, which is the function that you decide if this object should be broadcasted based on the subscriber. This function, in the example, only had 2 parameters, but in the source code of withFilter, it actually has 4 parameters, the forth one is the Info, which is the one you need!

    You may also notice that there's a resolve function for Apollo's subscription too. This means, after when it is broadcasting the payload to the client, you can modify the payload in that function.

    Subscription{
      voucherSent:{
        resolve: (payload, args, context, info)=>{
           // Here the info should tell you that the user also subscribed to  owner field
           // Use payload.value, or id, do model.voucher.include(owner) to construct the nested fields
           // return new payload. 
        },
        subscribe: (p,a,c,Info)=>{
            // Return an  asyncIterator object. 
        }
      }
    }
    

    In this setup, your subscription should at least work, but it may not be optimized. Because anytime there's a broadcast, the server will, possible make a database query for each subscribers. This resolver is called for each asyncIterator.next. The way to optimize it is that you cannot rely on the asyncIterator and modify the payload for each subscriber, you'll need to first loop through all your subscribers, know the union of all fields they subscribed. For example, if user 1

    subscribe{voucherSent(id:1){id, name}}
    

    and user 2

    subscribe{ voucherSent(id:1){name, sentAt, owner{id,name}}}
    

    You will need to put them together, and know you will need to make one trip to database. Pretend you are querying for

    getVoucher(id:1){
      id
      name
      sentAt
      owner{
        id
        name
      }
    }
    

    Then send back this union payload. This will require you to manually store all these subscribers in a store, and handle them in onConnect, onDisconnect. Also figure out how to combine these queries.

    Hope this helps, let me know!