0

I have a graphql query response of the shape

{
  table {
    id
    legs {
      id
    }
  }

This normalizes to table and leg entries in my InMemoryCache.

But then if my application retrieves a leg from cache and needs to know the corresponding table, how would I find this?

The two ideas I had are

  • adding a table prop to each leg when the query response comes in - not sure if/how that would work (I have multiple queries and mutations containing the graphql fragment with above shape)
  • having a suitable cache redirect, but I don't know how to do this without searching all tables for the leg.

Does apollo provide any features suitable to achieve this inverse lookup?

Update: to clarify, the leg has a table prop, but I since I already have the info in the client before resolving that prop, I'd like to resolve that prop client-side instead of server-side.

bebbi
  • 2,489
  • 3
  • 22
  • 36

1 Answers1

1

You should be adding a table prop to each leg. According to the graphql.org documentation you should be thinking in graphs:

With GraphQL, you model your business domain as a graph by defining a schema; within your schema, you define different types of nodes and how they connect/relate to one another.

In your model, tables and legs are nodes in your business model graph. When you add a table prop to each leg you are creating a new edge in this graph that your client-side code can traverse to get the relevant data.

Edit after clarification:

You can use writeFragment and to gain fine grained control of the Apollo cache. Once the cache filling query is done, compute the inverse relationship and write it to the cache like so:

fetchTables = async () => {
  const client = this.props.client

  const result = await client.query({
    query: ALL_TABLES_QUERY,
    variables: {}
  })

  // compute the reverse link
  const tablesByLeg = {}
  for (const table of result.data.table) {
    for (const leg of table.legs) {
      if (!tablesByLeg[leg.id]) {
        tablesByLeg[leg.id] = {
          leg: leg,
          tables: []
        }
      }
      tablesByLeg[leg.id].tables.push(table)
    }
  }

  // write to the Apollo cache
  for (const { leg, tables } of Object.values(tablesByLeg)) {
    client.writeFragment({
      id: dataIdFromObject(leg),
      fragment: gql`
        fragment reverseLink from Leg {
          id
          tables {
            id
          }
        }
      `,
      data: {
        ...leg,
        tables
      }
    })
  }

  // update component state
  this.setState(state => ({
    ...state,
    tables: Object.values(result)
  }))
}

Demo

I put up a complete exemple here: https://codesandbox.io/s/6vx0m346z I also put it below just for completeness sake.

index.js

import React from "react";
import ReactDOM from "react-dom";
import { ApolloProvider } from "react-apollo";
import { createClient } from "./client";
import { Films } from "./Films";

const client = createClient();

function App() {
  return (
    <ApolloProvider client={client}>
      <Films />
    </ApolloProvider>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

client.js

import { ApolloClient } from "apollo-client";
import { InMemoryCache } from "apollo-cache-inmemory";
import { HttpLink } from "apollo-link-http";

export function dataIdFromObject(object) {
  return object.id ? object.__typename + ":" + object.id : null;
}

export function createClient() {
  return new ApolloClient({
    connectToDevTools: true,
    ssrMode: false,
    link: new HttpLink({
      uri: "https://prevostc-swapi-graphql.herokuapp.com"
    }),
    cache: new InMemoryCache({
      dataIdFromObject,
      cacheRedirects: {
        Query: {
          planet: (_, args, { getCacheKey }) =>
            getCacheKey({ __typename: "Planet", id: args.id })
        }
      }
    })
  });
}

Films.js

import React from "react";
import gql from "graphql-tag";
import { withApollo } from "react-apollo";
import { dataIdFromObject } from "../src/client";
import { Planet } from "./Planet";

const ALL_FILMS_QUERY = gql`
  query {
    allFilms {
      films {
        id
        title
        planetConnection {
          planets {
            id
            name
          }
        }
      }
    }
  }
`;

const REVERSE_LINK_FRAGMENT = gql`
  fragment reverseLink on Planet {
    id
    name
    filmConnection {
      films {
        id
        title
      }
    }
  }
`;

class FilmsComponent extends React.Component {
  constructor() {
    super();
    this.state = { films: [], selectedPlanetId: null };
  }

  componentDidMount() {
    this.fetchFilms();
  }

  fetchFilms = async () => {
    const result = await this.props.client.query({
      query: ALL_FILMS_QUERY,
      variables: {}
    });

    // compute the reverse link
    const filmByPlanet = {};
    for (const film of result.data.allFilms.films) {
      for (const planet of film.planetConnection.planets) {
        if (!filmByPlanet[planet.id]) {
          filmByPlanet[planet.id] = {
            planet: planet,
            films: []
          };
        }
        filmByPlanet[planet.id].films.push(film);
      }
    }

    // write to the apollo cache
    for (const { planet, films } of Object.values(filmByPlanet)) {
      this.props.client.writeFragment({
        id: dataIdFromObject(planet),
        fragment: REVERSE_LINK_FRAGMENT,
        data: {
          ...planet,
          filmConnection: {
            films,
            __typename: "PlanetsFilmsConnection"
          }
        }
      });
    }

    // update component state at last
    this.setState(state => ({
      ...state,
      films: Object.values(result.data.allFilms.films)
    }));
  };

  render() {
    return (
      <div>
        {this.state.selectedPlanetId && (
          <div>
            <h1>Planet query result</h1>
            <Planet id={this.state.selectedPlanetId} />
          </div>
        )}
        <h1>All films</h1>
        {this.state.films.map(f => {
          return (
            <ul key={f.id}>
              <li>id: {f.id}</li>
              <li>
                title: <strong>{f.title}</strong>
              </li>
              <li>__typename: {f.__typename}</li>
              <li>
                planets:
                {f.planetConnection.planets.map(p => {
                  return (
                    <ul key={p.id}>
                      <li>id: {p.id}</li>
                      <li>
                        name: <strong>{p.name}</strong>
                      </li>
                      <li>__typename: {p.__typename}</li>
                      <li>
                        <button
                          onClick={() =>
                            this.setState(state => ({
                              ...state,
                              selectedPlanetId: p.id
                            }))
                          }
                        >
                          select
                        </button>
                      </li>
                      <li>&nbsp;</li>
                    </ul>
                  );
                })}
              </li>
            </ul>
          );
        })}
        <h1>The current cache is:</h1>
        <pre>{JSON.stringify(this.props.client.extract(), null, 2)}</pre>
      </div>
    );
  }
}

export const Films = withApollo(FilmsComponent);

Planet.js

import React from "react";
import gql from "graphql-tag";
import { Query } from "react-apollo";

const PLANET_QUERY = gql`
  query ($id: ID!) {
    planet(id: $id) {
      id
      name
      filmConnection {
        films {
          id
          title
        }
      }
    }
  }
`;

export function Planet({ id }) {
  return (
    <Query query={PLANET_QUERY} variables={{ id }}>
      {({ loading, error, data }) => {
        if (loading) return "Loading...";
        if (error) return `Error! ${error.message}`;

        const p = data.planet;
        return (
          <ul key={p.id}>
            <li>id: {p.id}</li>
            <li>
              name: <strong>{p.name}</strong>
            </li>
            <li>__typename: {p.__typename}</li>
            {p.filmConnection.films.map(f => {
              return (
                <ul key={f.id}>
                  <li>id: {f.id}</li>
                  <li>
                    title: <strong>{f.title}</strong>
                  </li>
                  <li>__typename: {f.__typename}</li>
                  <li>&nbsp;</li>
                </ul>
              );
            })}
          </ul>
        );
      }}
    </Query>
  );
}
Clément Prévost
  • 8,000
  • 2
  • 36
  • 51
  • `legs` have a `table` prop already, I just want to resolve that prop client-side instead of server-side. – bebbi Sep 15 '18 at 20:49
  • Oh sorry I did not understand your question properly. Then I suggest that you use your first option but with Apollo's `client.writeFragment` because it's the recommended way of writing arbitrary data to the in memory cache – Clément Prévost Sep 15 '18 at 21:57
  • OK, but the question is then, what is a suitable lifecycle hook to call `writeFragment` in, given the above situation? 1. What `` lifecycle hook can be used? 2. Is there something even better given multiple queries share the fragment? – bebbi Sep 17 '18 at 09:44
  • 1. I would not use `` but `` because you need fine grained control with the query firing lifecycle. 2. if I get it right, the goal is to fill the cache asap so other queries should only use the cache, so using `writeFragment` at the proper tile. I will create a codesandbox to demonstrate – Clément Prévost Sep 17 '18 at 10:08
  • Yes, asap/for other queries is best. Thanks, I didn't know ApolloConsumer. Looking forward to see where you'd place that component. (Perhaps around any Query/Mutation comps for which the selection set imports that fragment, or even more elegant?) – bebbi Sep 17 '18 at 15:44
  • My plan is to call `writeFragment` once the normalizing query is done to fill the `leg -> table` relation. This way, a normal query that fetches `table -> legs -> table -> table` should be able to use the cache only. I'm not 100% certain that will work though :s – Clément Prévost Sep 17 '18 at 16:02
  • I have to try :) – Clément Prévost Sep 20 '18 at 17:52
  • I got this worked out and edited the answer accordingly :) – Clément Prévost Sep 22 '18 at 21:16
  • One quick question to confirm: Changing the store after the query fulfills - of course, good idea. But If I have multiple queries containing the same fragment at arbitrary places - there's no way abstracting that and I will need to add that custom logic to every query that contains a graphql selection importing that `table` fragment, correct? – bebbi Sep 23 '18 at 19:26
  • Not necessarily, notice in the demo code how I used a cache redirect to map the `planet(id: "someID")` query with the `Planet:someID` cache entry so that the client doesn't have to make a network call. If you can provide the proper cache redirects, then you can do it client side only. – Clément Prévost Sep 23 '18 at 19:34