Skip to content

Commit

Permalink
Add a CacheKeyApolloResolver based on ApolloResolver (incubating) (ap…
Browse files Browse the repository at this point in the history
  • Loading branch information
BoD committed Jul 1, 2024
1 parent 7548c6f commit 192ca82
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 21 deletions.
10 changes: 5 additions & 5 deletions docs/source/caching/programmatic-ids.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ You can get the current object's typename from the `context` object and include
```kotlin
val cacheKeyGenerator = object : CacheKeyGenerator {
override fun cacheKeyForObject(obj: Map<String, Any?>, context: CacheKeyGeneratorContext): CacheKey? {
val typename = context.field.type.leafType().name
val typename = context.field.type.rawType().name
val id = obj["id"] as String

return CacheKey(typename, id)
Expand All @@ -54,7 +54,7 @@ val cacheKeyGenerator = object : CacheKeyGenerator {
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 an Union as opposed to the expected runtime value of the type received in the response. Instead querying for the `__typename` is safer.
Also, for interfaces and unions, `context.field.type.rawType().name` yields the typename as it is declared in the schema, as opposed to the runtime value of the type received in the response. Instead querying for the `__typename` is safer.
To make sure `__typename` is included in all operations set the [addTypename](https://www.apollographql.com/docs/kotlin/kdoc/apollo-gradle-plugin-external/com.apollographql.apollo3.gradle.api/-service/add-typename.html) gradle config:

```
Expand All @@ -73,8 +73,8 @@ The `CacheKeyResolver` class enables you to generate custom cache IDs from a fie
val cacheKeyResolver = object: CacheKeyResolver() {
override fun cacheKeyForField(field: CompiledField, variables: Executable.Variables): CacheKey? {
// [field] contains compile-time information about what type of object is being resolved.
// Even though we call leafType() here, we're guaranteed that the type is a composite type and not a list
val typename = field.type.leafType().name
// Even though we call rawType() here, we're guaranteed that the type is a composite type and not a list
val typename = field.type.rawType().name

// argumentValue returns the runtime value of the "id" argument
// from either the variables or as a literal value
Expand Down Expand Up @@ -127,7 +127,7 @@ To have the cache look up _all_ books in the `ids` list, we need to override `li
```kotlin
override fun listOfCacheKeysForField(field: CompiledField, variables: Executable.Variables): List<CacheKey?>? {
// Note that the field *can* be a list type here
val typename = field.type.leafType().name
val typename = field.type.rawType().name

// argumentValue returns the runtime value of the "id" argument
// from either the variables or as a literal value
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.apollographql.apollo3.cache.normalized.api

import com.apollographql.apollo3.annotations.ApolloExperimental
import com.apollographql.apollo3.api.CompiledField
import com.apollographql.apollo3.api.CompiledListType
import com.apollographql.apollo3.api.CompiledNamedType
Expand All @@ -9,31 +10,31 @@ import com.apollographql.apollo3.api.isComposite
import kotlin.jvm.JvmSuppressWildcards

/**
* A [CacheResolver] that resolves objects and list of objects and fallbacks to the default resolver for scalar fields.
* A [CacheResolver] that resolves objects and list of objects and falls back to the default resolver for scalar fields.
* It is intended to simplify the usage of [CacheResolver] when no special handling is needed for scalar fields.
*
* Override [cacheKeyForField] to compute a cache key for a field of composite type.
* Override [listOfCacheKeysForField] to compute a list of cache keys for a field of 'list-of-composite' type.
*
* For simplicity, this only handles one level of list. Implement [CacheResolver] if you need arbitrary nested lists of objects.
* For simplicity, this only handles one level of lists. Implement [CacheResolver] if you need arbitrary nested lists of objects.
*/
abstract class CacheKeyResolver : CacheResolver {
/**
* Return the computed the cache key for a composite field.
* Returns the computed cache key for a composite field.
*
* If the field is of object type, you can get the object typename with `field.type.rawType().name`
* If the field is of interface type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* If the field is of object type, you can get the object typename with `field.type.rawType().name`.
* If the field is of interface or union type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* in the whole schema as it cannot be namespaced by the typename anymore.
*
* If the returned [CacheKey] is null, the resolver will use the default handling and use any previously cached value.
*/
abstract fun cacheKeyForField(field: CompiledField, variables: Executable.Variables): CacheKey?

/**
* For a field that contains a list of objects, [listOfCacheKeysForField ] returns a list of [CacheKey]s where each [CacheKey] identifies an object.
* For a field that contains a list of objects, [listOfCacheKeysForField] returns a list of [CacheKey]s where each [CacheKey] identifies an object.
*
* If the field is of object type, you can get the object typename with `field.type.rawType().name`
* If the field is of interface type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* If the field is of object type, you can get the object typename with `field.type.rawType().name`.
* If the field is of interface or union type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* in the whole schema as it cannot be namespaced by the typename anymore.
*
* If an individual [CacheKey] is null, the resulting object will be null in the response.
Expand Down Expand Up @@ -74,3 +75,66 @@ abstract class CacheKeyResolver : CacheResolver {
return DefaultCacheResolver.resolveField(field, variables, parent, parentId)
}
}

/**
* An [ApolloResolver] that resolves objects and list of objects and falls back to the default resolver for scalar fields.
* It is intended to simplify the usage of [ApolloResolver] when no special handling is needed for scalar fields.
*
* Override [cacheKeyForField] to compute a cache key for a field of composite type.
* Override [listOfCacheKeysForField] to compute a list of cache keys for a field of 'list-of-composite' type.
*
* For simplicity, this only handles one level of lists. Implement [ApolloResolver] if you need arbitrary nested lists of objects.
*/
@ApolloExperimental
abstract class CacheKeyApolloResolver : ApolloResolver {
/**
* Returns the computed cache key for a composite field.
*
* If the field is of object type, you can get the object typename with `field.type.rawType().name`.
* If the field is of interface or union type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* in the whole schema as it cannot be namespaced by the typename anymore.
*
* If the returned [CacheKey] is null, the resolver will use the default handling and use any previously cached value.
*/
abstract fun cacheKeyForField(context: ResolverContext): CacheKey?

/**
* For a field that contains a list of objects, [listOfCacheKeysForField] returns a list of [CacheKey]s where each [CacheKey] identifies an object.
*
* If the field is of object type, you can get the object typename with `field.type.rawType().name`.
* If the field is of interface or union type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* in the whole schema as it cannot be namespaced by the typename anymore.
*
* If an individual [CacheKey] is null, the resulting object will be null in the response.
* If the returned list of [CacheKey]s is null, the resolver will use the default handling and use any previously cached value.
*/
open fun listOfCacheKeysForField(context: ResolverContext): List<CacheKey?>? = null

final override fun resolveField(context: ResolverContext): Any? {
var type = context.field.type
if (type is CompiledNotNullType) {
type = type.ofType
}
if (type is CompiledNamedType && type.isComposite()) {
val result = cacheKeyForField(context)
if (result != null) {
return result
}
}

if (type is CompiledListType) {
type = type.ofType
if (type is CompiledNotNullType) {
type = type.ofType
}
if (type is CompiledNamedType && type.isComposite()) {
val result = listOfCacheKeysForField(context)
if (result != null) {
return result
}
}
}

return DefaultApolloResolver.resolveField(context)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.apollographql.apollo3.cache.normalized

import com.apollographql.apollo3.api.CompiledField
import com.apollographql.apollo3.api.CompiledListType
import com.apollographql.apollo3.api.Executable
import com.apollographql.apollo3.api.ObjectType
import com.apollographql.apollo3.cache.normalized.CacheKeyApolloResolverTest.Fixtures.TEST_LIST_FIELD
import com.apollographql.apollo3.cache.normalized.CacheKeyApolloResolverTest.Fixtures.TEST_SIMPLE_FIELD
import com.apollographql.apollo3.cache.normalized.api.CacheHeaders
import com.apollographql.apollo3.cache.normalized.api.CacheKey
import com.apollographql.apollo3.cache.normalized.api.CacheKeyApolloResolver
import com.apollographql.apollo3.cache.normalized.api.DefaultFieldKeyGenerator
import com.apollographql.apollo3.cache.normalized.api.ResolverContext
import com.apollographql.apollo3.exception.CacheMissException
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.fail


class CacheKeyApolloResolverTest {

private lateinit var subject: CacheKeyApolloResolver
lateinit var onCacheKeyForField: (context: ResolverContext) -> CacheKey?
lateinit var onListOfCacheKeysForField: (context: ResolverContext) -> List<CacheKey?>?

@BeforeTest
fun setup() {
subject = FakeCacheKeyApolloResolver()
onCacheKeyForField = { _ ->
fail("Unexpected call to cacheKeyForField")
}
onListOfCacheKeysForField = { _ ->
fail("Unexpected call to listOfCacheKeysForField")
}
}

private fun resolverContext(field: CompiledField) =
ResolverContext(field, Executable.Variables(emptyMap()), emptyMap(), "", "", CacheHeaders(emptyMap()), DefaultFieldKeyGenerator)

@Test
fun verify_cacheKeyForField_called_for_named_composite_field() {
val expectedKey = CacheKey("test")
val fields = mutableListOf<CompiledField>()

onCacheKeyForField = { context: ResolverContext ->
fields += context.field
expectedKey
}

val returned = subject.resolveField(resolverContext(TEST_SIMPLE_FIELD))

assertEquals(returned, expectedKey)
assertEquals(fields[0], TEST_SIMPLE_FIELD)
}

@Test
fun listOfCacheKeysForField_called_for_list_field() {
val expectedKeys = listOf(CacheKey("test"))
val fields = mutableListOf<CompiledField>()

onListOfCacheKeysForField = { context: ResolverContext ->
fields += context.field
expectedKeys
}

val returned = subject.resolveField(resolverContext(TEST_LIST_FIELD))

assertEquals(returned, expectedKeys)
assertEquals(fields[0], TEST_LIST_FIELD)
}

@Test
fun super_called_for_null_return_values() {
onCacheKeyForField = { _ -> null }
onListOfCacheKeysForField = { _ -> null }

// The best way to ensure that super was called is to check for a cache miss exception from CacheResolver()
assertFailsWith<CacheMissException> {
subject.resolveField(resolverContext(TEST_SIMPLE_FIELD))
}
assertFailsWith<CacheMissException> {
subject.resolveField(resolverContext(TEST_LIST_FIELD))
}
}

inner class FakeCacheKeyApolloResolver : CacheKeyApolloResolver() {

override fun cacheKeyForField(context: ResolverContext): CacheKey? {
return onCacheKeyForField(context)
}

override fun listOfCacheKeysForField(context: ResolverContext): List<CacheKey?>? {
return onListOfCacheKeysForField(context)
}
}

object Fixtures {

private val TEST_TYPE = ObjectType.Builder(name = "Test").keyFields(keyFields = listOf("id")).build()

val TEST_SIMPLE_FIELD = CompiledField.Builder(name = "test", type = TEST_TYPE).build()

val TEST_LIST_FIELD = CompiledField.Builder(name = "testList", type = CompiledListType(ofType = TEST_TYPE)).build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,31 @@ import com.apollographql.apollo3.api.isComposite
import kotlin.jvm.JvmSuppressWildcards

/**
* A [CacheResolver] that resolves objects and list of objects and fallbacks to the default resolver for scalar fields.
* A [CacheResolver] that resolves objects and list of objects and falls back to the default resolver for scalar fields.
* It is intended to simplify the usage of [CacheResolver] when no special handling is needed for scalar fields.
*
* Override [cacheKeyForField] to compute a cache key for a field of composite type.
* Override [listOfCacheKeysForField] to compute a list of cache keys for a field of 'list-of-composite' type.
*
* For simplicity, this only handles one level of list. Implement [CacheResolver] if you need arbitrary nested lists of objects.
* For simplicity, this only handles one level of lists. Implement [CacheResolver] if you need arbitrary nested lists of objects.
*/
abstract class CacheKeyResolver : CacheResolver {
/**
* Return the computed the cache key for a composite field.
* Returns the computed cache key for a composite field.
*
* If the field is of object type, you can get the object typename with `field.type.rawType().name`
* If the field is of interface type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* If the field is of object type, you can get the object typename with `field.type.rawType().name`.
* If the field is of interface or union type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* in the whole schema as it cannot be namespaced by the typename anymore.
*
* If the returned [CacheKey] is null, the resolver will use the default handling and use any previously cached value.
*/
abstract fun cacheKeyForField(field: CompiledField, variables: Executable.Variables): CacheKey?

/**
* For a field that contains a list of objects, [listOfCacheKeysForField ] returns a list of [CacheKey]s where each [CacheKey] identifies an object.
* For a field that contains a list of objects, [listOfCacheKeysForField] returns a list of [CacheKey]s where each [CacheKey] identifies an object.
*
* If the field is of object type, you can get the object typename with `field.type.rawType().name`
* If the field is of interface type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* If the field is of object type, you can get the object typename with `field.type.rawType().name`.
* If the field is of interface or union type, the concrete object typename is not predictable and the returned [CacheKey] must be unique
* in the whole schema as it cannot be namespaced by the typename anymore.
*
* If an individual [CacheKey] is null, the resulting object will be null in the response.
Expand Down

0 comments on commit 192ca82

Please sign in to comment.