From 192ca82ffad7dca71c17521cc1bee2f1781ce194 Mon Sep 17 00:00:00 2001 From: Benoit 'BoD' Lubek Date: Fri, 14 Jun 2024 18:39:19 +0200 Subject: [PATCH] Add a CacheKeyApolloResolver based on ApolloResolver (incubating) (#5970) --- docs/source/caching/programmatic-ids.mdx | 10 +- .../cache/normalized/api/CacheKeyResolver.kt | 80 +++++++++++-- .../normalized/CacheKeyApolloResolverTest.kt | 107 ++++++++++++++++++ .../cache/normalized/api/CacheKeyResolver.kt | 16 +-- 4 files changed, 192 insertions(+), 21 deletions(-) create mode 100644 libraries/apollo-normalized-cache-api-incubating/src/commonTest/kotlin/com/apollographql/apollo3/cache/normalized/CacheKeyApolloResolverTest.kt diff --git a/docs/source/caching/programmatic-ids.mdx b/docs/source/caching/programmatic-ids.mdx index ade74e532a5..6cfe27cc200 100644 --- a/docs/source/caching/programmatic-ids.mdx +++ b/docs/source/caching/programmatic-ids.mdx @@ -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, 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) @@ -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: ``` @@ -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 @@ -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? { // 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 diff --git a/libraries/apollo-normalized-cache-api-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/CacheKeyResolver.kt b/libraries/apollo-normalized-cache-api-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/CacheKeyResolver.kt index 636301f650a..fc1db671d15 100644 --- a/libraries/apollo-normalized-cache-api-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/CacheKeyResolver.kt +++ b/libraries/apollo-normalized-cache-api-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/CacheKeyResolver.kt @@ -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 @@ -9,20 +10,20 @@ 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. @@ -30,10 +31,10 @@ abstract class CacheKeyResolver : CacheResolver { 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. @@ -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? = 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) + } +} diff --git a/libraries/apollo-normalized-cache-api-incubating/src/commonTest/kotlin/com/apollographql/apollo3/cache/normalized/CacheKeyApolloResolverTest.kt b/libraries/apollo-normalized-cache-api-incubating/src/commonTest/kotlin/com/apollographql/apollo3/cache/normalized/CacheKeyApolloResolverTest.kt new file mode 100644 index 00000000000..b95fcfd33d2 --- /dev/null +++ b/libraries/apollo-normalized-cache-api-incubating/src/commonTest/kotlin/com/apollographql/apollo3/cache/normalized/CacheKeyApolloResolverTest.kt @@ -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? + + @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() + + 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() + + 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 { + subject.resolveField(resolverContext(TEST_SIMPLE_FIELD)) + } + assertFailsWith { + 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? { + 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() + } +} diff --git a/libraries/apollo-normalized-cache-api/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/CacheKeyResolver.kt b/libraries/apollo-normalized-cache-api/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/CacheKeyResolver.kt index 636301f650a..e41ca321e25 100644 --- a/libraries/apollo-normalized-cache-api/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/CacheKeyResolver.kt +++ b/libraries/apollo-normalized-cache-api/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/CacheKeyResolver.kt @@ -9,20 +9,20 @@ 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. @@ -30,10 +30,10 @@ abstract class CacheKeyResolver : CacheResolver { 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.