apollo-feature-requests icon indicating copy to clipboard operation
apollo-feature-requests copied to clipboard

Instead of updating queries' cache after mutation, let ObservableQuery to watch mutations

Open stalniy opened this issue 6 years ago • 16 comments

Problem

Currently when you run a mutation, there is an update function which can help to update cache for existing queries. The issue is that at that moment you don't have neither query reference nor query variables, so it's hard to read query cache and keep code clean (not interdependent).

Proposed solution

Allow ObservableQuery to subscribe to mutations because at the moment of query creation we have variables and query reference.

For example:

const MY_QUERY = gql`
   query getProducts($page: Int!) {
      products(page: $page) {
         items {
            sku
            title
         }
      }
   }
`

const CREATE_MUTATION = gql`
  mutation createProduct($product: product!) {
      details {
          sku
          title
      }
  }
`

const query = apollo.watchQuery({
   query: MY_QUERY,
   variables: { /* ... */ },
})

query.on(CREATE_MUTATION, (current, response) => {
   return current.items.concat(response.createProduct.details)
})

query.subscribe((items) => {
   // do something with items
}) 

Mutation subscriptions must be dropped when tearDownQuery is called (basically when there is no subscribers anymore)

Why is it better

Because we have reference to query and query.variables. And the best place to decide whether your query cache needs to react to mutation changes is in query creation, not in mutation creation

P.S.: I've already done this by monkey patching ObservableQuery class

stalniy avatar Mar 04 '19 17:03 stalniy

I may have a naive understanding, but I like this approach to cache updates a lot, @stalniy. Are you willing to share your monkey patch or publish it as an npm library?

KeithGillette avatar Mar 04 '19 20:03 KeithGillette

I actually tried to integrate Vue.js reactivity into Apollo Cache, so the code is a bit messy but you can take a look here: https://github.com/stalniy/apollo-cache-vue-sample/blob/master/src/plugins/apollo.js#L98

The usage in store is here - https://github.com/stalniy/apollo-cache-vue-sample/blob/master/src/store.js#L87

and this is how it's used in component - https://github.com/stalniy/apollo-cache-vue-sample/blob/master/src/components/HelloWorld.vue#L99

stalniy avatar Mar 05 '19 05:03 stalniy

This what bother me from time to time:) nice stuff! this should be going forward

ygrishajev avatar Mar 06 '19 13:03 ygrishajev

In attempting to work-around some query update issues in our Angular project, I hacked together a similar monkey patch of Apollo-Angular based directly on @stalniy's ApolloClient Vue example. Doing so required a bit of reworking since ObservableQuery is not exposed in Apollo Angular to maintain RxJS compatibility but other than exposing the registration method on QueryRef instead of ObservableQuery, the structure is almost identical.

I made two other unrelated significant changes:

  1. I changed the signature of the query update function registered to the existing MutationUpdaterFn used in the update method used in a mutate call so basically the invocation of the update function on a mutation is passed-through to the registered query update functions. While this requires query update functions to manually read from & write to the cache instead of that being handled automatically, by exposing DataProxy as a parameter to the query updater functions, the updater can perform any other queries or updates that may be needed to correctly update the query in question or application state in general without needing to otherwise inject ApolloClient.
  2. I added an optional, experimental override to removing the query update watches in tearDownQuery, as I found that unsubscribed queries remaining in the cache otherwise became stale so that when the same query was again invoked, outdated data was displayed. This, of course, is a bad idea, especially since there's no guard against the same update method for the same query and variables.

KeithGillette avatar Mar 21 '19 22:03 KeithGillette

Updated my repository https://github.com/stalniy/apollo-cache-vue-sample Now it's finished (no support for subscriptions yet) example of Vue and apollo integration with easy cache updates (like Vuex).

Added description in README, so it makes some sense to others

stalniy avatar Apr 15 '19 09:04 stalniy

This is how I now can define GraphqlClient

export const graphQlClient = new GraphQlClient({
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    }
  },
  cache: new ApolloCache({
    data: {
      ui: {
        __typename: 'UI',
        token: 'test'
      }
    }
  }),
  configureCache: {
    getPoints(query) {
      query.on(POINT_GQL.create, (current, response, { variables }) => {
        if (current.items.length < variables.pagination.pageSize) {
          current.items.push(response.createPoint.details)
        }

        current.meta.total++
      })

      query.on(POINT_GQL.remove, (current, response) => {
        const id = response.deletePoint.details.id
        current.items = current.items.filter(item => item.id !== id)
        current.meta.total--
      })

      query.on(FETCH_MORE, (current, response) => {
        return {
          meta: response.meta,
          items: current.items.concat(response.items)
        }
      })
    }
  },
  link: setContextLink
    .concat(new HttpLink({
      uri: 'http://localhost:4444/api/gql'
    })),
})

stalniy avatar Apr 15 '19 12:04 stalniy

Any thoughts from Apollo team?

stalniy avatar Jun 09 '19 19:06 stalniy

Can't you follow relay approach?

https://relay.dev/docs/en/mutations#range_add

You should return a new edge on output of your mutation, then you can add the new edge to your existing connections of your store

sibelius avatar Jul 20 '19 00:07 sibelius

The big problem with this approach that mutation need to know which connections (I.e. queries to update). So it’s a direct dependency between queries and mutations what makes updates handling BIG PAIN.

In the proposed solution, query defines dependency on mutation. This allows for active queries to subscribe to possible mutations in the app and react to them. If query is not active we don’t need to update its cache. Because you will refetch data from server.

This approach scales better and works much better.

stalniy avatar Jul 20 '19 04:07 stalniy

The big problem with this approach that mutation need to know which connections (I.e. queries to update). So it’s a direct dependency between queries and mutations what makes updates handling BIG PAIN.

There still is a cross dependency on your approach, just the other way around. Note: I'm not dismissing your idea, I rather like it.

AndreiCalazans avatar Jul 20 '19 12:07 AndreiCalazans

Yes. It’s. But it’s much easier to handle. Because you have query instance and its variables. So you can easily update cache.

In case when you do this on mutation level, you don’t have query variables.

Usually mutation affects a lot of queries and a query usually is affected only by 2 mutations (create, delete). Update mutation is handled automatically by Apollo

Update: by create and delete I mean cases that adds record to collection or it from there. Sometimes it may include update mutation as well. But again in this case you have query variables so you can effectively identify whether it’s a ā€œmoving recordā€ mutation. Because of the sub/pub pattern you don’t need to iterate over all dependent queries and update their cache. So, the code is simpler and can be moved into centralized place.

Update 2: it’s also possible to define which queries are affected by mutation and store dependencies separately (implemented in https://github.com/haytko/apollo-link-watched-mutation) but it involves more complicated logic to achieve this

stalniy avatar Jul 20 '19 14:07 stalniy

Not much sure how Apollo store works, but in Relay all queries all stored in a normalized way (read more here https://medium.com/@sibelius/relay-modern-the-relay-store-8984cd148798). So you don't focus on updating queries but to update correctly your store data.

Relay uses hooks or fragmentContainers to keep track of which component needs each data, so if you update the store it will refresh all components that asked for that data

Usually you don't want to update all "queries" or connection after a mutation, this could be very application logic specific

sibelius avatar Jul 20 '19 15:07 sibelius

Apollo cache is very similar to relay cache:

  1. Normalized
  2. You can read/write fragment directly from/to the cache, having fragmentId (key id in the cache)
  3. You can read/write query directly from/to the cache, having reference to the query and its variables. usually on mutation side you don’t have variables that’s why @connection directive was added. But it doesn’t help much when you have standard pagination or/and filters.
  4. It supports connection queries

stalniy avatar Jul 21 '19 05:07 stalniy

I think that the whole concept of updating cache on mutation is WRONG and came from redux/Vuex experience.

Let’s consider CQRS and EventSourcing which is a backend analogy of redux-like solutions on UI. In this case you have 2 separate databases: write db (sequence of events) and read db.

The responsibility of write db is just write events in sequence, validate BL and notify read db that there are changes. Read db subscribed to the events and reacts to events in the way which makes sense for this db. So, it responsible for updating its own cache (usually a worker group related to read model).

If we map this to Apollo cache, we will see that mutations side is a write db (so it just need to send mutation and notify read model). Read model is our graphql queries. So, needs to subscribe to events and update is own cache.

This approach works more naturally and simpler because we separate concerns.

Mutation side should not care what somebody will do with the cache it’s responsibility of the cache owner (in this case graphql queries)

stalniy avatar Jul 21 '19 05:07 stalniy

Hi, just a question, sorry if it sounds naive: will your approach work after the initial server render ? Does it require the query to have been run once client-side or will it also work if it has only been run server side ?

eric-burel avatar Sep 27 '19 13:09 eric-burel

We'll look into expanding the arguments/options passed to the mutation update function. context and variables have already been added (since this FR was created): https://github.com/apollographql/apollo-client/pull/7902

If you have suggestions for additional context options (e.g. mutation DocumentNode), definitely let us know. Thanks!

hwillson avatar Sep 28 '21 15:09 hwillson