Establishing one-to-many relationship between two types in Apollo Client cache

Hello everyone! I have a rather obvious problem that I can’t seem to solve.

Let’s say I have a Category type and a Product type. A category can have multiple products, but a product can only have one category. Let’s say the schema is

type Category {
  id: ID!
  products: [Product!]!
}

type Product {
  id: ID!
  name: String!
  category: Category!
}

type Query {
  category(id: ID!): Category
  product(id: ID!): Product
}

As you can see, type Product has one category linked to it. Also, I can query a category by ID or a product by ID.

Now let’s say I make a query

query {
  category(id: 1) {
    id
    products {
      id
      name
    } 
  }
}

Basically here I’m querying a specific category and all products within it.

Let’s say as a result I got a single product with ID 2:

{
  "category": {
    "id": 1,
    "products": [ { "id": 2, "name": "My product" } ]
  }
}

Apollo will save the result into the cache. After that I make this 2nd query:

query {
  product(id: 2) {
    id
    name
    category {
      id
    }
  }
}

I know that the category of this product has ID = 1, because in the previous query, the product with ID = 2 was inside the category with ID = 1. But unfortunately Apollo Client does not know that, so the 2nd query will be a cache miss.

How do I solve this? How do I “link” every product within a category to that category, so Apollo “understands” that if products X and Y are in the products array of the category A, then category A is in the category field of the product X?

Thank you!

Hey @SimpleCreations :wave:

What you’re looking for is to specify a type policy that tells the cache to look up that category field in the cache. Check out this section on cache redirects which should get you what you need. In your case, instead of specifying the type policy for the Query type, you’ll want your Product type (or whatever __typename that product field returns).

Hopefully this helps!

Hi @jerelmiller! Thank you for the reply.

I still don’t understand how to write a cache redirect for my particular case. The problem is that the category field on Product type doesn’t not have an id argument (because every product has a specific category). So I can’t use args.id. Maybe you could give me a hint?

export const TYPE_POLICIES: TypePolicies = {
  Query: {
    fields: {
      category: {
        read: (_, { args, toReference }) =>
          toReference({ __typename: 'Category', id: args?.id })
      },
      product: {
        read: (_, { args, toReference }) =>
          toReference({ __typename: 'Product', id: args?.id })
      },
    }
  },
  Product: {
    fields: {
      category: {
        read: (_, { args, toReference }) =>
          // This will not work, because there is never any `args.id`
          toReference({ __typename: 'Category', id: args?.id })
      },
    }
  },
}

Ah you’re right, sorry I replied too fast and didn’t think through this super quickly.

This seems like such a basic case, but I’m coming up a bit empty on a solution myself. The problem is that we don’t give you the parent object in your read function, so even if you were to record the mapping between product → category externally, you have no way to look up the current product.id in your Product.category read function.

Let me ask around and see if someone knows a trick I don’t.

@SimpleCreations found a solution that works. I just had to play around with it a little bit. Check out this codesandbox.

The key thing that makes this solution work is that I add the category field on the product entity in a Category merge function.

      Category: {
        merge(existing, incoming, { readField, mergeObjects, toReference }) {
          const products = readField('products', incoming)

          // ensure we only try and merge the category into the product if products have been selected
          if (products) {
            products.forEach(productRef => {
              mergeObjects(productRef, { category: toReference(incoming) })
            })
          }

          // Ensure we maintain the existing behavior of merging fields together
          return mergeObjects(existing, incoming)
        }

This checks to see if the incoming Category is merging products along with it, and if so, we set the category on each product to the incoming category. Since the category field is now cached on the product, the query that fetches the product won’t need to hit the network.

FYI this demo doesn’t hit a network endpoint, so I added console logs in the local schema resolvers so you know when the “network” is hit. Comment out the mergeObjects line in the products loop to toggle seeing it hit the network or not when you select a product in the UI.

Let me know if you have any questions, but hopefully this works well for you!

This is exactly what I was looking for!

Thank you for the help! Looks like you took a lot of time to dive into this, and I greatly appreciate it.

1 Like

@jerelmiller hi!

So I tried making use of this in my production project. There’s a small issue I ran into, I was wondering if you have a hint for this.

Let’s say I have an argument on the products field inside Category.

type Category {
  id: ID!
  # The selection of products can vary by country
  products(countryCode: String!): [Product!]!
}

# (the rest is the same)

The logic I need is the same — every nested product (regardless of the argument value) should have its inner category reference set to the outer category. But I’m struggling to make use of the readField function. If I use it like in your example — readField('products', incoming) — it always returns an empty result due to the arguments being included in the key for this field. I can’t use the args option from readField’s API because I don’t know the argument value.

Here’s the workaround I found, but it’s quite ugly:

    merge: (
      existing,
      incoming,
      { readField, mergeObjects, toReference }
    ) => {
      for (const [fieldName, fieldValue] of Object.entries(incoming)) {
        if (fieldName.startsWith('products(')) {
          for (const ref of fieldValue) {
            mergeObjects(ref, { product: toReference(incoming) })
          }
        }
      }
      return mergeObjects(existing, incoming)
    }

Do you think there’s anything better I can do? Any way to use readField for this?
E.g. something like:

const products = readField({
  from: incoming,
  fieldName: 'products',
  ignoreArgs: true
})

Thanks!