Programmatic cache IDs in Apollo Kotlin
In situations where declarative cache IDs don't fit your use case, you can programmatically generate cache IDs for object types in your normalized cache.
You can generate a given object type's cache ID from one of two sources:
Source | Class | Description |
---|---|---|
From a response object's fields (e.g., Book.id ) | CacheKeyGenerator | This happens after a network request and is essential to merging a query result with existing cached data. This is the most common case. |
From a GraphQL operation's arguments (e.g., author(id: "au456") ) | CacheKeyResolver | This happens before a network request and enables you to avoid a network round trip if all requested data is in the cache already. This is an optional optimization that can avoid some cache misses. |
Apollo Kotlin provides a class for generating cache keys from each of these sources.
CacheKeyGenerator
The CacheKeyGenerator
class enables you to generate custom cache IDs from an object's field values. This basic example generates every object type's cache ID from its id
field:
1val cacheKeyGenerator = object : CacheKeyGenerator {
2 override fun cacheKeyForObject(obj: Map<String, Any?>, context: CacheKeyGeneratorContext): CacheKey? {
3 // Generate the cache ID based on the object's id field
4 return CacheKey(obj["id"] as String)
5 }
6}
To use your custom CacheKeyGenerator
, include it in your cache initialization code like so:
1val apolloClient = ApolloClient.Builder()
2 .serverUrl("https://...")
3 .normalizedCache(
4 normalizedCacheFactory = cacheFactory,
5 cacheKeyGenerator = cacheKeyGenerator,
6 )
7 .build()
You can get the current object's typename from the context
object and include it in the generated ID, like so:
1val cacheKeyGenerator = object : CacheKeyGenerator {
2 override fun cacheKeyForObject(obj: Map<String, Any?>, context: CacheKeyGeneratorContext): CacheKey? {
3 val typename = obj["__typename"] as String
4 val id = obj["id"] as String
5
6 return CacheKey(typename, id)
7 }
8}
You can also use the current object's typename to use different cache ID generation logic for different object types.
Note that for cache ID generation to work, your GraphQL operations must return whatever fields your custom code relies on (such as id
above). If a query does not return a required field, the cache ID will be inconsistent, resulting in data duplication.
Also, using context.field.type.leafType().name
yields the typename of the Union as opposed to the expected runtime value of Union received in the response. Instead querying for the __typename
is safer.
To make sure __typename
is included in all operations set the adTypename
gradle config:
1apollo {
2 addTypename.set("always")
3 //
4}
CacheKeyResolver
The CacheKeyResolver
class enables you to generate custom cache IDs from an field's arguments. This basic example generates every object type's cache ID from the id
argument if it's present:
1val cacheKeyResolver = object: CacheKeyResolver() {
2 override fun cacheKeyForField(field: CompiledField, variables: Executable.Variables): CacheKey? {
3 // [field] contains compile-time information about what type of object is being resolved.
4 // Even though we call leafType() here, we're guaranteed that the type is a composite type and not a list
5 val typename = field.type.leafType().name
6
7 // resolveArgument returns the runtime value of the "id" argument
8 // from either the variables or as a literal value
9 val id = field.resolveArgument("id", variables)
10
11 if (id is String) {
12 // This field has an id argument, so we can use it to compute a cache ID
13 return CacheKey(typename, id)
14 }
15
16 // Return null to use the default handling
17 return null
18 }
19}
To use your custom CacheKeyResolver
, include it in your cache initialization code like so:
1val apolloClient = ApolloClient.Builder()
2 .serverUrl("https://...")
3 .normalizedCache(
4 normalizedCacheFactory = cacheFactory,
5 cacheKeyGenerator = cacheKeyGenerator,
6 cacheResolver = cacheKeyResolver
7 )
8 .build()
Note the following about using a custom CacheKeyResolver
:
The
cacheKeyForField
function is called for every field in your operation that returns a composite type, so it's important to returnnull
if you don't want to handle a particular field.The function is not called for fields that return a list of composite types. See below.
Handling lists
Let's say we have this query:
1query GetBooks($ids: [String!]!) {
2 books(ids: $ids) {
3 id
4 title
5 }
6}
To have the cache look up all books in the ids
list, we need to override listOfCacheKeysForField
in CacheKeyResolver
:
1override fun listOfCacheKeysForField(field: CompiledField, variables: Executable.Variables): List<CacheKey?>? {
2 // Note that the field *can* be a list type here
3 val typename = field.type.leafType().name
4
5 // resolveArgument returns the runtime value of the "id" argument
6 // from either the variables or as a literal value
7 val ids = field.resolveArgument("ids", variables)
8
9 if (ids is List<*>) {
10 // This field has an id argument, so we can use it to compute a cache key
11 return ids.map { CacheKey(typename, it as String) }
12 }
13
14 // Return null to use the default handling
15 return null
16}
For the sake of simplicity, only one level of list is supported. To support more nested lists, you can implement CacheResolver
. CacheResolver
is a generalization of CacheKeyResolver
that can return any value from the cache, even scalar values:
1val cacheResolver = object: CacheResolver {
2 override fun resolveField(
3 field: CompiledField,
4 variables: Executable.Variables,
5 parent: Map<String, @JvmSuppressWildcards Any?>,
6 parentId: String,
7 ): Any? {
8
9 var type = field.type
10 var listDepth = 0
11
12 while (true) {
13 when (type) {
14 is CompiledNotNullType -> type = type.ofType
15 is CompiledListType -> {
16 listDepth++
17 type = type.ofType
18 }
19 else -> break
20 }
21 }
22
23 // Now type points to the leaf type and lestDepth is the nesting of lists required
24
25 // Return a kotlin value for this field
26 // No type checking is done here so it must match the expected GraphQL type
27
28 if (listDepth == 2) {
29 return listOf(listOf("0", "1"))
30 }
31
32 // CacheResolver must always call DefaultCacheResolver last or all fields will be null else
33 return DefaultCacheResolver.resolveField(field, variables, parent, parentId)
34 }
35}