Skip to content

Normalised Cache Not Using Data from Different Query #3345

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
reubn opened this issue Feb 25, 2024 · 10 comments
Closed

Normalised Cache Not Using Data from Different Query #3345

reubn opened this issue Feb 25, 2024 · 10 comments
Labels

Comments

@reubn
Copy link

reubn commented Feb 25, 2024

Summary

I have a very simple use case that doesn't seem to be handled by the normalised cache properly.

I have a query that returns a list of Countrys using a CountryInfo fragment.

query AllCountriesQuery {
  countries {
    ...CountryInfo
  }
}
fragment CountryInfo on Country {
  code
  name
  emoji
}

I then have a second query that queries for a specific Country, returning the same fragment.

query CountryDetailQuery($code: ID!) {
  country(code: $code) {
    ...CountryInfo
  }
}

The data returned for the first query populates the cache, but is not used for the second query. Apollo returns the following error.

GraphQLExecutionError(path: country, underlying: ApolloAPI.JSONDecodingError.missingValue)

I have set up custom cache keys, but Apollo seems to only correctly return cached data for the same query, despite the 2 pieces of data in the cache having the same cache key. This is unexpected.

By introspecting the SQLiteNormalizedCache database I can see that the data from the first query is stored and keyed correctly, but this not appear to be read in the second. The test case behaves identically with the InMemoryNormalizedCache.

I can see that a similar issue was reported #842, and if this behaviour is intentional then the question is: how can I implement cache redirects to normalise identical objects across queries?

Thanks!

Minimal test case: https://github.com/reubn/apolloiOSBugTestCase

Artboard

Version

1.9.0

Steps to reproduce the behavior

Minimal test case: https://github.com/reubn/apolloiOSBugTestCase

Logs

No response

Anything else?

No response

@reubn reubn added bug Generally incorrect behavior needs investigation labels Feb 25, 2024
@Iron-Ham
Copy link
Contributor

Iron-Ham commented Feb 26, 2024

Hi @reubn

I took a quick look at your minimal test case, and I think that there's a bit of a mis-understanding of how the cache functions. Within your CountryDetailView, you're attempting to create an object of type CountryDetailQuery.Data.Country by fetching a CountryDetailQuery via the cache. However, as you've never fetched CountryDetailQuery before, you don't have a cache entry for that query.

As you said, you're really looking for the country field – which is being supplied by a shared fragment CountryInfo. You can, instead of storing CountryDetailQuery.Data.Country, store CountryInfo. And instead of fetching CountryDetailQuery, you can do a read operation on the cache for a CountryInfo.

Your fetch data function becomes something like this:

  func fetchData() {
    GraphQL.shared.apollo.store.withinReadTransaction { transaction in
      let countryInfo = try transaction.readObject(ofType: CountryInfo.self, withKey: "Country:\(code)")
      self.country = countryInfo
    }
  }

More important that having a fix (which may or may not work for your use-case) is understanding why what you're attempting to do fails.

Imagine, for a moment, that your CountryDataQuery was much more complex:

query CountryDetailQuery($code: ID!) {
  country(code: $code) {
    ...CountryInfo
    population
    officialLanguage
    currentLeader
    governmentType
    # etc
  }
}

At this point, there's a very clear divergence between the AllCountriesQuery.Country and CountryDataQuery.Country data types. Further, when we ask the cache to fetch a whole query, we must have executed that query before in the past. That's because of how queries are keyed.

For example, a CountryDetailQuery with code AD would be keyed as QUERY_ROOT.CountryDetailQuery(code: "AD"), with selections you have keyed similarly (QUERY_ROOT.CountryDetailQuery(code: "AD").country(code: "AD"). When you ask the cache to read for a query, there is no entry in the cache that resolves to that query, as we've never executed it. At its core, returnCacheDataDontFetch will resolve to code that's quite similar to the code I've written above for fetchData but will instead tell the transaction to read for the CountryDetailQuery, not the CountryInfo fragment.

@AnthonyMDev
Copy link
Contributor

I'm closing this issue due to inactivity. If this is still unresolved, feel free to provide more information and we can re-open this, or create a new issue.

@AnthonyMDev AnthonyMDev closed this as not planned Won't fix, can't repro, duplicate, stale May 17, 2024
Copy link
Contributor

Do you have any feedback for the maintainers? Please tell us by taking a one-minute survey. Your responses will help us understand Apollo iOS usage and allow us to serve you better.

@comontes
Copy link

I'm experiencing the same issue. And reading the docs in https://www.apollographql.com/docs/ios/caching/introduction I was under the impression that "Normalizing objects by cache key" would make Apollo fetch from cache even when is a different query since it's asking for exactly the same info a previous query already cached with the right cache key to be the same identifier?

@AnthonyMDev
Copy link
Contributor

@comontes That is how it should work generally. If you are not seeing the expected behavior, please open an issue with specific information about your use case. Preferably, including a reproduction case we can look at to help us assist you.

@dhritzkiv
Copy link
Contributor

We too have been bit by this on iOS, especially since I've come to expect this from the Kotlin Apollo client, which offers CacheResolver (specifically CacheKeyResolver) to deal with this, which is particularly useful for our node(id: $ID!) queries. I believe the JS client also offers similar behaviour out of the box (it's been a while since I've worked with it, so don't quote me on that one)

One area where it's a pain is when setting up watch queries globally:

For a contrived example, say we have a query:

query CurrentUserFilms {
    id
    title
    rating
}

which returns a list of Film objects, lets say.

Then, we want to set up a global watch query to update the UI of an on-screen film when only the rating changes anywhere in the app:

query FilmRatingById($id: ID!) {
    node(id: $id) {
        ... on Film {
            id
            rating
        }
    }
}

Making sure to set cachePolicy to .returnCacheDataDontFetch as we already have the data in the cache, and don't want to hit the network to fetch data we ostensibly already have, especially since FilmRatingById would create n network requests, where n is each list item. However, the query fails with a JSONDecodingError.missingValue error (as node(id: $id) has never been populated in the cache)

What I've resorted to is to manually mutate the cache to write the Film data from CurrentUserFilms as a FilmRatingById query, which is cumbersome and doesn't scale well, especially compared to the Kotlin client's "automatic", practically default behaviour.

The watch query is just one example, but it has other implications for returnCacheDataDontFetch, returnCacheDataElseFetch, and returnCacheDataAndFetch queries, where the cache is missed for many queries.

@comontes
Copy link

comontes commented Mar 21, 2025

@comontes That is how it should work generally. If you are not seeing the expected behavior, please open an issue with specific information about your use case. Preferably, including a reproduction case we can look at to help us assist you.

Thanks, I opened issue #3534

@AnthonyMDev
Copy link
Contributor

Ohhh, I see now. I missed the point of this. You're talking about something akin to the @fieldPolicy directive of Apollo Kotlin.

That is on our current roadmap, but we haven't started development of the feature yet.

@temideoye
Copy link

Hi @AnthonyMDev — really appreciate your work on this library.

I also ran into this limitation last week, and it genuinely caught me off guard — largely because my experience with Apollo on the web had led me to expect that normalized cache reuse would “just work” across related queries. After reading your note about @fieldPolicy being on the roadmap, I spent some time exploring a modest solution that could help bridge the gap in the meantime, particularly for common patterns like getEntityById after getEntities.

Recap

As others on this thread have noted, Apollo iOS currently misses the cache for queries like:

query CountryByCode($code: ID!) {
  country(code: $code) {
     ...CountryFragment
  }
}

…even when the data already exists in the cache from a previous query like:

query AllCountries {
  countries {
     ...CountryFragment
  }
}

Because the CountryByCode query hasn’t been executed before, ApolloStore has no way of knowing there's a normalized object with the requested shape and identity from the AllCountries query.

Proposed Solution

As a stop-gap, add a simple, non-breaking hook to SchemaConfiguration:

static func cacheResolverInfo<Operation: GraphQLOperation>(
  for operation: Operation.Type,
  variables: Operation.Variables?
) -> CacheResolverInfo?

This hook allows library consumers to provide metadata that helps ApolloStore identify the target of an operation so it can attempt to resolve the result from existing cache data—even if it has never been executed.

The returned CacheResolverInfo includes:

  • cacheKey: the normalized object key (e.g., "Country:AE"), as defined by cacheKeyInfo(for:object:)
  • rootSelectionSetType: the selection set used to read and interpret the cached object (e.g., CountryFragment.self)
  • rootFieldKey: the top-level field (if any) in the operation’s Data object where the result should be keyed (e.g., "country").

ApolloStore uses this to perform a targeted cache read and synthesize a valid GraphQLResult<Operation.Data>. If the object is missing or incomplete, it gracefully falls back to the default resolution strategy.

Benefits

  • Enables cache hits for unexecuted but semantically resolvable operations, such as getItem(id:) after a broader getItems() query.
  • Fully backward-compatible, with a default implementation that returns nil.
  • Minimal and focused surface area.
  • Potentially lays the groundwork for future support of @fieldPolicy, following the model established in Apollo Kotlin.

Implementation

I’ve published a working solution in a GitHub fork. The changes are small and thoroughly documented:

  1. SchemaConfiguration.swift
  2. ApolloStore.swift

Would your team be open to reviewing a PR for this? I’d be happy to tighten it up or align with any design considerations you have in mind.
Thanks again for your thoughtful leadership on the project — excited to see this area evolve.

@AnthonyMDev
Copy link
Contributor

Thanks for all the work you've done on that! Our team would love to work with you on a PR for this feature, however this approach isn't going to work for us.

Currently, this PR would only support fixing this issue for a single field within an operation. We should actually be focusing on allowing this to be configured for each field individually.

Adding an additional configuration hook into the SchemaMetadata is definitely the right way to approach this, but we would want a function that is called for each entity field to determine how to configure the cache key for that field. This configuration should also affect both reading and writing data. I think your current implementation only handles the reading side.

Once we have that mechanism in place, you could programmatically configure these, but it would also make adding the @fieldPolicy directive trivial, as it would just be a declarative syntax for doing the same configuration that this PR would support.

This is on our roadmap, but we have some other work that we are prioritizing right now, so no guarantees when we will get to it. If you would like to work on a PR for this though, we would be happy to support you through that process!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

6 participants