From 2d2da325ef24244511b0077c5f2f7b326764ea90 Mon Sep 17 00:00:00 2001 From: BoD Date: Tue, 30 Jul 2024 14:07:25 +0200 Subject: [PATCH 1/7] Add ExpirationCacheResolver --- .../cache/normalized/ClientCacheExtensions.kt | 14 ++- .../cache/normalized/api/CacheResolver.kt | 91 ++++++++++--------- .../cache/normalized/api/MaxAgeProvider.kt | 40 ++++++++ .../kotlin/ClientSideExpirationTest.kt | 11 +-- .../kotlin/ServerSideExpirationTest.kt | 56 +++++++----- 5 files changed, 138 insertions(+), 74 deletions(-) create mode 100644 normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/MaxAgeProvider.kt diff --git a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/ClientCacheExtensions.kt b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/ClientCacheExtensions.kt index bc28ba7f..f4c8d534 100644 --- a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/ClientCacheExtensions.kt +++ b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/ClientCacheExtensions.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlin.jvm.JvmName import kotlin.jvm.JvmOverloads +import kotlin.time.Duration enum class FetchPolicy { /** @@ -401,6 +402,13 @@ fun MutableExecutionOptions.cacheHeaders(cacheHeaders: CacheHeaders) = ad CacheHeadersContext(cacheHeaders) ) +/** + * @param maxStale how long to accept stale fields + */ +fun MutableExecutionOptions.maxStale(maxStale: Duration) = cacheHeaders( + CacheHeaders.Builder().addHeader(ApolloCacheHeaders.MAX_STALE, maxStale.inWholeSeconds.toString()).build() +) + /** * @param writeToCacheAsynchronously whether to return the response before writing it to the cache * @@ -698,6 +706,10 @@ val ApolloResponse.cacheHeaders * * Any [FetchPolicy] previously set will be ignored */ -@Deprecated("Use fetchPolicy(FetchPolicy.CacheAndNetwork) instead", ReplaceWith("fetchPolicy(FetchPolicy.CacheAndNetwork).toFlow()"), level = DeprecationLevel.ERROR) +@Deprecated( + "Use fetchPolicy(FetchPolicy.CacheAndNetwork) instead", + ReplaceWith("fetchPolicy(FetchPolicy.CacheAndNetwork).toFlow()"), + level = DeprecationLevel.ERROR +) @ApolloDeprecatedSince(ApolloDeprecatedSince.Version.v3_7_5) fun ApolloCall.executeCacheAndNetwork(): Flow> = TODO() diff --git a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt index ffbfc8c6..0e155e71 100644 --- a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt +++ b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt @@ -2,8 +2,12 @@ package com.apollographql.cache.normalized.api import com.apollographql.apollo.api.CompiledField import com.apollographql.apollo.api.Executable +import com.apollographql.apollo.api.MutableExecutionOptions import com.apollographql.apollo.exception.CacheMissException import com.apollographql.apollo.mpp.currentTimeMillis +import com.apollographql.cache.normalized.maxStale +import com.apollographql.cache.normalized.storeExpirationDate +import com.apollographql.cache.normalized.storeReceiveDate import kotlin.jvm.JvmSuppressWildcards /** @@ -121,58 +125,61 @@ object DefaultCacheResolver : CacheResolver { } /** - * A cache resolver that uses the cache date as a receive date and expires after a fixed max age + * A cache resolver that raises a cache miss if the field's received date is older than its max age + * (configurable via [maxAgeProvider]) or its expiration date has passed. + * + * Received dates are stored by calling `storeReceiveDate(true)` on your `ApolloClient`. + * + * Expiration dates are stored by calling `storeExpirationDate(true)` on your `ApolloClient`. + * + * A maximum staleness can be configured via the [ApolloCacheHeaders.MAX_STALE] cache header. + * + * @see MutableExecutionOptions.storeReceiveDate + * @see MutableExecutionOptions.storeExpirationDate + * @see MutableExecutionOptions.maxStale */ -class ReceiveDateCacheResolver(private val maxAge: Int) : CacheResolver { +class ExpirationCacheResolver( + private val maxAgeProvider: MaxAgeProvider, +) : CacheResolver { override fun resolveField(context: ResolverContext): Any? { - val parent = context.parent - val parentKey = context.parentKey - - val fieldKey = context.fieldKeyGenerator.getFieldKey(FieldKeyContext(context.parentType, context.field, context.variables)) - if (!parent.containsKey(fieldKey)) { - throw CacheMissException(parentKey, fieldKey) - } - - if (parent is Record) { - val receivedDate = parent.receivedDate(fieldKey) - if (receivedDate != null) { - val maxStale = context.cacheHeaders.headerValue(ApolloCacheHeaders.MAX_STALE)?.toLongOrNull() ?: 0L - if (maxStale < Long.MAX_VALUE) { - val age = currentTimeMillis() / 1000 - receivedDate - if (maxAge + maxStale - age < 0) { - throw CacheMissException(parentKey, fieldKey, true) + val resolvedField = FieldPolicyCacheResolver.resolveField(context) + if (context.parent is Record) { + val field = context.field + val maxStale = context.cacheHeaders.headerValue(ApolloCacheHeaders.MAX_STALE)?.toLongOrNull() ?: 0L + val currentDate = currentTimeMillis() / 1000 + + // Consider the field's max age (client side) + val fieldMaxAge = maxAgeProvider.getMaxAge(MaxAgeContext(field = field, parentType = context.parentType))?.inWholeSeconds + if (fieldMaxAge != null) { + val fieldReceivedDate = context.parent.receivedDate(field.name) + if (fieldReceivedDate != null) { + val fieldAge = currentDate - fieldReceivedDate + val stale = fieldAge - fieldMaxAge + if (stale >= maxStale) { + throw CacheMissException( + context.parentKey, + context.fieldKeyGenerator.getFieldKey(FieldKeyContext(context.parentType, context.field, context.variables)), + true + ) } } } - } - - return parent[fieldKey] - } -} - -/** - * A cache resolver that uses the cache date as an expiration date and expires past it - */ -class ExpireDateCacheResolver : CacheResolver { - override fun resolveField(context: ResolverContext): Any? { - val parent = context.parent - val parentKey = context.parentKey - - val fieldKey = context.fieldKeyGenerator.getFieldKey(FieldKeyContext(context.parentType, context.field, context.variables)) - if (!parent.containsKey(fieldKey)) { - throw CacheMissException(parentKey, fieldKey) - } - if (parent is Record) { - val expirationDate = parent.expirationDate(fieldKey) - if (expirationDate != null) { - if (currentTimeMillis() / 1000 - expirationDate >= 0) { - throw CacheMissException(parentKey, fieldKey, true) + // Consider the field's expiration date (server side) + val fieldExpirationDate = context.parent.expirationDate(field.name) + if (fieldExpirationDate != null) { + val stale = currentDate - fieldExpirationDate + if (stale >= maxStale) { + throw CacheMissException( + context.parentKey, + context.fieldKeyGenerator.getFieldKey(FieldKeyContext(context.parentType, context.field, context.variables)), + true + ) } } } - return parent[fieldKey] + return resolvedField } } diff --git a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/MaxAgeProvider.kt b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/MaxAgeProvider.kt new file mode 100644 index 00000000..850844e4 --- /dev/null +++ b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/MaxAgeProvider.kt @@ -0,0 +1,40 @@ +package com.apollographql.cache.normalized.api + +import com.apollographql.apollo.api.CompiledField +import kotlin.time.Duration + +interface MaxAgeProvider { + /** + * Returns the max age for the given type and field. + * @return null if no max age is defined for the given type and field. + */ + fun getMaxAge(maxAgeContext: MaxAgeContext): Duration? +} + +class MaxAgeContext( + val field: CompiledField, + val parentType: String, +) + +/** + * A provider that returns a single max age for all types. + */ +class GlobalMaxAgeProvider(private val maxAge: Duration) : MaxAgeProvider { + override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration = maxAge +} + +/** + * A provider that returns a max age based on [schema coordinates](https://github.com/graphql/graphql-spec/pull/794). + * The given coordinates must be object (e.g. `MyType`) or field (e.g. `MyType.myField`) coordinates. + * If a field matches both field and object coordinates, the field ones are used. + */ +class SchemaCoordinatesMaxAgeProvider( + private val coordinatesToDurations: Map, + private val defaultMaxAge: Duration? = null, +) : MaxAgeProvider { + override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration? { + return coordinatesToDurations["${maxAgeContext.parentType}.${maxAgeContext.field.name}"] + ?: coordinatesToDurations[maxAgeContext.parentType] + ?: defaultMaxAge + } +} diff --git a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt index d20bc710..755e495f 100644 --- a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt @@ -24,6 +24,7 @@ import com.apollographql.cache.normalized.storeReceiveDate import sqlite.GetUserQuery import kotlin.test.Test import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds class ClientSideExpirationTest { @Test @@ -46,14 +47,12 @@ class ClientSideExpirationTest { val client = ApolloClient.Builder() .normalizedCache( normalizedCacheFactory = normalizedCacheFactory, - cacheKeyGenerator = TypePolicyCacheKeyGenerator, - cacheResolver = ReceiveDateCacheResolver(maxAge), - recordMerger = DefaultRecordMerger, - metadataGenerator = EmptyMetadataGenerator, + cacheResolver = ExpirationCacheResolver(GlobalMaxAgeProvider(maxAge.seconds)), ) - .storeReceiveDate(true) .serverUrl("unused") .build() + client.apolloStore.clearAll() + val query = GetUserQuery() val data = GetUserQuery.Data(GetUserQuery.User("John", "john@doe.com")) @@ -69,7 +68,7 @@ class ClientSideExpirationTest { // with max stale, should succeed val response1 = client.query(GetUserQuery()).fetchPolicy(FetchPolicy.CacheOnly) - .cacheHeaders(CacheHeaders.Builder().addHeader(ApolloCacheHeaders.MAX_STALE, "10").build()) + .maxStale(10.seconds) .execute() assertTrue(response1.data?.user?.name == "John") diff --git a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt index 39bee25e..32e8028f 100644 --- a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt @@ -4,20 +4,16 @@ import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.exception.CacheMissException import com.apollographql.apollo.testing.internal.runTest -import com.apollographql.cache.normalized.FetchPolicy -import com.apollographql.cache.normalized.api.ExpireDateCacheResolver -import com.apollographql.cache.normalized.api.MemoryCacheFactory -import com.apollographql.cache.normalized.api.NormalizedCacheFactory -import com.apollographql.cache.normalized.api.TypePolicyCacheKeyGenerator -import com.apollographql.cache.normalized.fetchPolicy -import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.cache.normalized.* +import com.apollographql.cache.normalized.api.* import com.apollographql.cache.normalized.sql.SqlNormalizedCacheFactory -import com.apollographql.cache.normalized.storeExpirationDate import com.apollographql.mockserver.MockResponse import com.apollographql.mockserver.MockServer import sqlite.GetUserQuery import kotlin.test.Test import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds class ServerSideExpirationTest { @Test @@ -39,14 +35,17 @@ class ServerSideExpirationTest { private fun test(normalizedCacheFactory: NormalizedCacheFactory) = runTest { val mockServer = MockServer() val client = ApolloClient.Builder() - .normalizedCache( - normalizedCacheFactory = normalizedCacheFactory, - cacheKeyGenerator = TypePolicyCacheKeyGenerator, - cacheResolver = ExpireDateCacheResolver() - ) - .storeExpirationDate(true) - .serverUrl(mockServer.url()) - .build() + .normalizedCache( + normalizedCacheFactory = normalizedCacheFactory, + cacheResolver = ExpirationCacheResolver(object : MaxAgeProvider { + override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration? = null + }) + ) + .storeExpirationDate(true) + .serverUrl(mockServer.url()) + .build() + client.apolloStore.clearAll() + val query = GetUserQuery() val data = """ { @@ -59,14 +58,14 @@ class ServerSideExpirationTest { } """.trimIndent() - val response: ApolloResponse + var response: ApolloResponse // store data with an expiration date in the future mockServer.enqueue( - MockResponse.Builder() - .addHeader("Cache-Control", "max-age=10") - .body(data) - .build() + MockResponse.Builder() + .addHeader("Cache-Control", "max-age=10") + .body(data) + .build() ) client.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() // read from cache -> it should succeed @@ -75,14 +74,21 @@ class ServerSideExpirationTest { // store expired data mockServer.enqueue( - MockResponse.Builder() - .addHeader("Cache-Control", "max-age=0") - .body(data) - .build() + MockResponse.Builder() + .addHeader("Cache-Control", "max-age=0") + .body(data) + .build() ) client.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() // read from cache -> it should fail val e = client.query(GetUserQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().exception as CacheMissException assertTrue(e.stale) + + // read from cache with a max stale -> no cache miss + response = client.query(GetUserQuery()) + .fetchPolicy(FetchPolicy.CacheOnly) + .maxStale(1.seconds) + .execute() + assertTrue(response.data?.user?.name == "John") } } From 38e029e0b55b5636944bfd1c83d25b9a02b68370 Mon Sep 17 00:00:00 2001 From: BoD Date: Tue, 30 Jul 2024 15:32:52 +0200 Subject: [PATCH 2/7] Add tests for SchemaCoordinatesMaxAgeProvider --- .../src/commonMain/graphql/operations.graphql | 27 +++- .../src/commonMain/graphql/schema.graphqls | 7 +- .../kotlin/ClientSideExpirationTest.kt | 131 ++++++++++++++---- .../kotlin/ServerSideExpirationTest.kt | 53 ++++--- 4 files changed, 170 insertions(+), 48 deletions(-) diff --git a/tests/expiration/src/commonMain/graphql/operations.graphql b/tests/expiration/src/commonMain/graphql/operations.graphql index 79ac2378..fb7b5984 100644 --- a/tests/expiration/src/commonMain/graphql/operations.graphql +++ b/tests/expiration/src/commonMain/graphql/operations.graphql @@ -2,5 +2,30 @@ query GetUser { user { name email + admin } -} \ No newline at end of file +} + +query GetUserAdmin { + user { + admin + } +} + +query GetUserEmail { + user { + email + } +} + +query GetUserName { + user { + name + } +} + +query GetCompany { + company { + id + } +} diff --git a/tests/expiration/src/commonMain/graphql/schema.graphqls b/tests/expiration/src/commonMain/graphql/schema.graphqls index 3ada9e89..bc11b626 100644 --- a/tests/expiration/src/commonMain/graphql/schema.graphqls +++ b/tests/expiration/src/commonMain/graphql/schema.graphqls @@ -1,9 +1,14 @@ type Query { user: User + company: Company } type User { name: String! email: String! admin: Boolean -} \ No newline at end of file +} + +type Company { + id: ID! +} diff --git a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt index 755e495f..1d118e65 100644 --- a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt @@ -5,44 +5,47 @@ import com.apollographql.apollo.api.CustomScalarAdapters import com.apollographql.apollo.exception.CacheMissException import com.apollographql.apollo.mpp.currentTimeMillis import com.apollographql.apollo.testing.internal.runTest -import com.apollographql.cache.normalized.FetchPolicy -import com.apollographql.cache.normalized.api.ApolloCacheHeaders -import com.apollographql.cache.normalized.api.CacheHeaders -import com.apollographql.cache.normalized.api.DefaultRecordMerger -import com.apollographql.cache.normalized.api.EmptyMetadataGenerator -import com.apollographql.cache.normalized.api.MemoryCacheFactory -import com.apollographql.cache.normalized.api.NormalizedCacheFactory -import com.apollographql.cache.normalized.api.ReceiveDateCacheResolver -import com.apollographql.cache.normalized.api.TypePolicyCacheKeyGenerator -import com.apollographql.cache.normalized.api.normalize -import com.apollographql.cache.normalized.apolloStore -import com.apollographql.cache.normalized.cacheHeaders -import com.apollographql.cache.normalized.fetchPolicy -import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.cache.normalized.* +import com.apollographql.cache.normalized.api.* import com.apollographql.cache.normalized.sql.SqlNormalizedCacheFactory -import com.apollographql.cache.normalized.storeReceiveDate -import sqlite.GetUserQuery +import sqlite.* import kotlin.test.Test import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds class ClientSideExpirationTest { @Test - fun memoryCache() { - test(MemoryCacheFactory()) + fun globalMaxAgeMemoryCache() { + globalMaxAge(MemoryCacheFactory()) } @Test - fun sqlCache() { - test(SqlNormalizedCacheFactory()) + fun globalMaxAgeSqlCache() { + globalMaxAge(SqlNormalizedCacheFactory()) } @Test - fun chainedCache() { - test(MemoryCacheFactory().chain(SqlNormalizedCacheFactory())) + fun globalMaxAgeChainedCache() { + globalMaxAge(MemoryCacheFactory().chain(SqlNormalizedCacheFactory())) } - private fun test(normalizedCacheFactory: NormalizedCacheFactory) = runTest { + @Test + fun schemaCoordinatesMaxAgeMemoryCache() { + schemaCoordinatesMaxAge(MemoryCacheFactory()) + } + + @Test + fun schemaCoordinatesMaxAgeSqlCache() { + schemaCoordinatesMaxAge(SqlNormalizedCacheFactory()) + } + + @Test + fun schemaCoordinatesMaxAgeChainedCache() { + schemaCoordinatesMaxAge(MemoryCacheFactory().chain(SqlNormalizedCacheFactory())) + } + + + private fun globalMaxAge(normalizedCacheFactory: NormalizedCacheFactory) = runTest { val maxAge = 10 val client = ApolloClient.Builder() .normalizedCache( @@ -54,7 +57,7 @@ class ClientSideExpirationTest { client.apolloStore.clearAll() val query = GetUserQuery() - val data = GetUserQuery.Data(GetUserQuery.User("John", "john@doe.com")) + val data = GetUserQuery.Data(GetUserQuery.User("John", "john@doe.com", true)) val records = query.normalize(data, CustomScalarAdapters.Empty, TypePolicyCacheKeyGenerator).values @@ -81,6 +84,86 @@ class ClientSideExpirationTest { assertTrue(response2.data?.user?.name == "John") } + private fun schemaCoordinatesMaxAge(normalizedCacheFactory: NormalizedCacheFactory) = runTest { + val maxAgeProvider = SchemaCoordinatesMaxAgeProvider( + mapOf( + "User" to 10.seconds, + "User.name" to 5.seconds, + "User.email" to 2.seconds, + ), + defaultMaxAge = 20.seconds, + ) + + val client = ApolloClient.Builder() + .normalizedCache( + normalizedCacheFactory = normalizedCacheFactory, + cacheResolver = ExpirationCacheResolver(maxAgeProvider), + ) + .serverUrl("unused") + .build() + client.apolloStore.clearAll() + + // Store records 25 seconds ago, more than default max age: should cache miss + mergeCompanyQueryResults(client, 25) + var e = client.query(GetCompanyQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().exception as CacheMissException + assertTrue(e.stale) + + // Store records 15 seconds ago, less than default max age: should not cache miss + mergeCompanyQueryResults(client, 15) + // Company fields are not configured so the default max age should be used + val companyResponse = client.query(GetCompanyQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertTrue(companyResponse.data?.company?.id == "42") + + + // Store records 15 seconds ago, more than max age for User: should cache miss + mergeUserQueryResults(client, 15) + e = client.query(GetUserAdminQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().exception as CacheMissException + assertTrue(e.stale) + + // Store records 5 seconds ago, less than max age for User: should not cache miss + mergeUserQueryResults(client, 5) + val userAdminResponse = client.query(GetUserAdminQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertTrue(userAdminResponse.data?.user?.admin == true) + + + // Store records 10 seconds ago, more than max age for User.name: should cache miss + mergeUserQueryResults(client, 10) + e = client.query(GetUserNameQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().exception as CacheMissException + assertTrue(e.stale) + + // Store records 2 seconds ago, less than max age for User.name: should not cache miss + mergeUserQueryResults(client, 2) + val userNameResponse = client.query(GetUserNameQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertTrue(userNameResponse.data?.user?.name == "John") + + + // Store records 5 seconds ago, more than max age for User.email: should cache miss + mergeUserQueryResults(client, 5) + e = client.query(GetUserEmailQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().exception as CacheMissException + assertTrue(e.stale) + + // Store records 1 second ago, less than max age for User.email: should not cache miss + mergeUserQueryResults(client, 1) + val userEmailResponse = client.query(GetUserEmailQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertTrue(userEmailResponse.data?.user?.email == "john@doe.com") + } + + private fun mergeCompanyQueryResults(client: ApolloClient, secondsAgo: Int) { + val data = GetCompanyQuery.Data(GetCompanyQuery.Company("42")) + val records = GetCompanyQuery().normalize(data, CustomScalarAdapters.Empty, TypePolicyCacheKeyGenerator).values + client.apolloStore.accessCache { + it.merge(records, cacheHeaders(currentTimeMillis() / 1000 - secondsAgo), DefaultRecordMerger) + } + } + + private fun mergeUserQueryResults(client: ApolloClient, secondsAgo: Int) { + val data = GetUserQuery.Data(GetUserQuery.User("John", "john@doe.com", true)) + val records = GetUserQuery().normalize(data, CustomScalarAdapters.Empty, TypePolicyCacheKeyGenerator).values + client.apolloStore.accessCache { + it.merge(records, cacheHeaders(currentTimeMillis() / 1000 - secondsAgo), DefaultRecordMerger) + } + } + private fun cacheHeaders(receivedDate: Long): CacheHeaders { return CacheHeaders.Builder().addHeader(ApolloCacheHeaders.RECEIVED_DATE, receivedDate.toString()).build() } diff --git a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt index 32e8028f..8f7b3fd1 100644 --- a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt @@ -4,9 +4,18 @@ import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.exception.CacheMissException import com.apollographql.apollo.testing.internal.runTest -import com.apollographql.cache.normalized.* -import com.apollographql.cache.normalized.api.* +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.ExpirationCacheResolver +import com.apollographql.cache.normalized.api.MaxAgeContext +import com.apollographql.cache.normalized.api.MaxAgeProvider +import com.apollographql.cache.normalized.api.MemoryCacheFactory +import com.apollographql.cache.normalized.api.NormalizedCacheFactory +import com.apollographql.cache.normalized.apolloStore +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.maxStale +import com.apollographql.cache.normalized.normalizedCache import com.apollographql.cache.normalized.sql.SqlNormalizedCacheFactory +import com.apollographql.cache.normalized.storeExpirationDate import com.apollographql.mockserver.MockResponse import com.apollographql.mockserver.MockServer import sqlite.GetUserQuery @@ -35,15 +44,15 @@ class ServerSideExpirationTest { private fun test(normalizedCacheFactory: NormalizedCacheFactory) = runTest { val mockServer = MockServer() val client = ApolloClient.Builder() - .normalizedCache( - normalizedCacheFactory = normalizedCacheFactory, - cacheResolver = ExpirationCacheResolver(object : MaxAgeProvider { - override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration? = null - }) - ) - .storeExpirationDate(true) - .serverUrl(mockServer.url()) - .build() + .normalizedCache( + normalizedCacheFactory = normalizedCacheFactory, + cacheResolver = ExpirationCacheResolver(object : MaxAgeProvider { + override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration? = null + }) + ) + .storeExpirationDate(true) + .serverUrl(mockServer.url()) + .build() client.apolloStore.clearAll() val query = GetUserQuery() @@ -62,10 +71,10 @@ class ServerSideExpirationTest { // store data with an expiration date in the future mockServer.enqueue( - MockResponse.Builder() - .addHeader("Cache-Control", "max-age=10") - .body(data) - .build() + MockResponse.Builder() + .addHeader("Cache-Control", "max-age=10") + .body(data) + .build() ) client.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() // read from cache -> it should succeed @@ -74,10 +83,10 @@ class ServerSideExpirationTest { // store expired data mockServer.enqueue( - MockResponse.Builder() - .addHeader("Cache-Control", "max-age=0") - .body(data) - .build() + MockResponse.Builder() + .addHeader("Cache-Control", "max-age=0") + .body(data) + .build() ) client.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() // read from cache -> it should fail @@ -86,9 +95,9 @@ class ServerSideExpirationTest { // read from cache with a max stale -> no cache miss response = client.query(GetUserQuery()) - .fetchPolicy(FetchPolicy.CacheOnly) - .maxStale(1.seconds) - .execute() + .fetchPolicy(FetchPolicy.CacheOnly) + .maxStale(1.seconds) + .execute() assertTrue(response.data?.user?.name == "John") } } From e303ae6db3087980a07d24a65d696836ab03eb50 Mon Sep 17 00:00:00 2001 From: BoD Date: Wed, 31 Jul 2024 17:32:32 +0200 Subject: [PATCH 3/7] Add ClientAndServerSideExpirationTest --- .../ClientAndServerSideExpirationTest.kt | 105 ++++++++++++++++++ .../kotlin/ClientSideExpirationTest.kt | 30 ++++- .../kotlin/ServerSideExpirationTest.kt | 3 +- 3 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt diff --git a/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt new file mode 100644 index 00000000..f5b4fd2f --- /dev/null +++ b/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt @@ -0,0 +1,105 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.exception.CacheMissException +import com.apollographql.apollo.mpp.currentTimeMillis +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.ExpirationCacheResolver +import com.apollographql.cache.normalized.api.MemoryCacheFactory +import com.apollographql.cache.normalized.api.NormalizedCacheFactory +import com.apollographql.cache.normalized.api.SchemaCoordinatesMaxAgeProvider +import com.apollographql.cache.normalized.apolloStore +import com.apollographql.cache.normalized.cacheHeaders +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.cache.normalized.sql.SqlNormalizedCacheFactory +import com.apollographql.cache.normalized.storeExpirationDate +import com.apollographql.mockserver.MockResponse +import com.apollographql.mockserver.MockServer +import sqlite.GetUserEmailQuery +import sqlite.GetUserNameQuery +import sqlite.GetUserQuery +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class ClientAndServerSideExpirationTest { + @Test + fun memoryCache() { + test(MemoryCacheFactory()) + } + + @Test + fun sqlCache() { + test(SqlNormalizedCacheFactory()) + } + + @Test + fun chainedCache() { + test(MemoryCacheFactory().chain(SqlNormalizedCacheFactory())) + } + + private fun test(normalizedCacheFactory: NormalizedCacheFactory) = runTest { + val mockServer = MockServer() + val client = ApolloClient.Builder() + .normalizedCache( + normalizedCacheFactory = normalizedCacheFactory, + cacheResolver = ExpirationCacheResolver( + SchemaCoordinatesMaxAgeProvider( + mapOf( + "User.email" to 2.seconds, + ), + defaultMaxAge = 20.seconds, + ) + ) + ) + .storeExpirationDate(true) + .serverUrl(mockServer.url()) + .build() + client.apolloStore.clearAll() + + val data = """ + { + "data": { + "user": { + "name": "John", + "email": "john@doe.com", + "admin": true + } + } + } + """.trimIndent() + + // Store data with an expiration date 10s in the future, and a received date 10s in the past + mockServer.enqueue( + MockResponse.Builder() + .addHeader("Cache-Control", "max-age=10") + .body(data) + .build() + ) + client.query(GetUserQuery()).fetchPolicy(FetchPolicy.NetworkOnly).cacheHeaders(cacheHeaders(currentTimeMillis() / 1000 - 10)).execute() + + // Read User.name from cache -> it should succeed + val userNameResponse = client.query(GetUserNameQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertTrue(userNameResponse.data?.user?.name == "John") + + // Read User.email from cache -> it should fail + var userEmailResponse = client.query(GetUserEmailQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute() + var e = userEmailResponse.exception as CacheMissException + assertTrue(e.stale) + + // Store data with an expired date of now + mockServer.enqueue( + MockResponse.Builder() + .addHeader("Cache-Control", "max-age=0") + .body(data) + .build() + ) + client.query(GetUserQuery()).fetchPolicy(FetchPolicy.NetworkOnly).execute() + // Read User.name from cache -> it should fail + userEmailResponse = client.query(GetUserEmailQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute() + e = userEmailResponse.exception as CacheMissException + assertTrue(e.stale) + } +} diff --git a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt index 1d118e65..67802f9a 100644 --- a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt @@ -5,10 +5,27 @@ import com.apollographql.apollo.api.CustomScalarAdapters import com.apollographql.apollo.exception.CacheMissException import com.apollographql.apollo.mpp.currentTimeMillis import com.apollographql.apollo.testing.internal.runTest -import com.apollographql.cache.normalized.* -import com.apollographql.cache.normalized.api.* +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.ApolloCacheHeaders +import com.apollographql.cache.normalized.api.CacheHeaders +import com.apollographql.cache.normalized.api.DefaultRecordMerger +import com.apollographql.cache.normalized.api.ExpirationCacheResolver +import com.apollographql.cache.normalized.api.GlobalMaxAgeProvider +import com.apollographql.cache.normalized.api.MemoryCacheFactory +import com.apollographql.cache.normalized.api.NormalizedCacheFactory +import com.apollographql.cache.normalized.api.SchemaCoordinatesMaxAgeProvider +import com.apollographql.cache.normalized.api.TypePolicyCacheKeyGenerator +import com.apollographql.cache.normalized.api.normalize +import com.apollographql.cache.normalized.apolloStore +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.maxStale +import com.apollographql.cache.normalized.normalizedCache import com.apollographql.cache.normalized.sql.SqlNormalizedCacheFactory -import sqlite.* +import sqlite.GetCompanyQuery +import sqlite.GetUserAdminQuery +import sqlite.GetUserEmailQuery +import sqlite.GetUserNameQuery +import sqlite.GetUserQuery import kotlin.test.Test import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds @@ -164,7 +181,8 @@ class ClientSideExpirationTest { } } - private fun cacheHeaders(receivedDate: Long): CacheHeaders { - return CacheHeaders.Builder().addHeader(ApolloCacheHeaders.RECEIVED_DATE, receivedDate.toString()).build() - } +} + +fun cacheHeaders(receivedDate: Long): CacheHeaders { + return CacheHeaders.Builder().addHeader(ApolloCacheHeaders.RECEIVED_DATE, receivedDate.toString()).build() } diff --git a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt index 8f7b3fd1..99411256 100644 --- a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt @@ -61,7 +61,8 @@ class ServerSideExpirationTest { "data": { "user": { "name": "John", - "email": "john@doe.com" + "email": "john@doe.com", + "admin": true } } } From 65eee16ddffa5c430058f1ae78c19a237353cbf0 Mon Sep 17 00:00:00 2001 From: BoD Date: Wed, 31 Jul 2024 17:51:09 +0200 Subject: [PATCH 4/7] Update API dump --- .../api/normalized-cache-incubating.api | 32 +++++++++++++++---- .../api/normalized-cache-incubating.klib.api | 29 +++++++++++++---- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/normalized-cache-incubating/api/normalized-cache-incubating.api b/normalized-cache-incubating/api/normalized-cache-incubating.api index 53a1f569..d4bb23a8 100644 --- a/normalized-cache-incubating/api/normalized-cache-incubating.api +++ b/normalized-cache-incubating/api/normalized-cache-incubating.api @@ -113,6 +113,7 @@ public final class com/apollographql/cache/normalized/NormalizedCache { public static final fun getCacheHeaders (Lcom/apollographql/apollo/api/ApolloResponse;)Lcom/apollographql/cache/normalized/api/CacheHeaders; public static final fun getCacheInfo (Lcom/apollographql/apollo/api/ApolloResponse;)Lcom/apollographql/cache/normalized/CacheInfo; public static final fun isFromCache (Lcom/apollographql/apollo/api/ApolloResponse;)Z + public static final fun maxStale-HG0u8IE (Lcom/apollographql/apollo/api/MutableExecutionOptions;J)Ljava/lang/Object; public static final fun memoryCacheOnly (Lcom/apollographql/apollo/api/MutableExecutionOptions;Z)Ljava/lang/Object; public static final fun optimisticUpdates (Lcom/apollographql/apollo/ApolloCall;Lcom/apollographql/apollo/api/Mutation$Data;)Lcom/apollographql/apollo/ApolloCall; public static final fun optimisticUpdates (Lcom/apollographql/apollo/api/ApolloRequest$Builder;Lcom/apollographql/apollo/api/Mutation$Data;)Lcom/apollographql/apollo/api/ApolloRequest$Builder; @@ -270,8 +271,8 @@ public final class com/apollographql/cache/normalized/api/EmptyMetadataGenerator public fun metadataForObject (Ljava/lang/Object;Lcom/apollographql/cache/normalized/api/MetadataGeneratorContext;)Ljava/util/Map; } -public final class com/apollographql/cache/normalized/api/ExpireDateCacheResolver : com/apollographql/cache/normalized/api/CacheResolver { - public fun ()V +public final class com/apollographql/cache/normalized/api/ExpirationCacheResolver : com/apollographql/cache/normalized/api/CacheResolver { + public fun (Lcom/apollographql/cache/normalized/api/MaxAgeProvider;)V public fun resolveField (Lcom/apollographql/cache/normalized/api/ResolverContext;)Ljava/lang/Object; } @@ -313,6 +314,22 @@ public abstract interface class com/apollographql/cache/normalized/api/FieldReco public abstract fun mergeFields (Lcom/apollographql/cache/normalized/api/FieldRecordMerger$FieldInfo;Lcom/apollographql/cache/normalized/api/FieldRecordMerger$FieldInfo;)Lcom/apollographql/cache/normalized/api/FieldRecordMerger$FieldInfo; } +public final class com/apollographql/cache/normalized/api/GlobalMaxAgeProvider : com/apollographql/cache/normalized/api/MaxAgeProvider { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getMaxAge-5sfh64U (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)J + public synthetic fun getMaxAge-LV8wdWc (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)Lkotlin/time/Duration; +} + +public final class com/apollographql/cache/normalized/api/MaxAgeContext { + public fun (Lcom/apollographql/apollo/api/CompiledField;Ljava/lang/String;)V + public final fun getField ()Lcom/apollographql/apollo/api/CompiledField; + public final fun getParentType ()Ljava/lang/String; +} + +public abstract interface class com/apollographql/cache/normalized/api/MaxAgeProvider { + public abstract fun getMaxAge-LV8wdWc (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)Lkotlin/time/Duration; +} + public final class com/apollographql/cache/normalized/api/MemoryCache : com/apollographql/cache/normalized/api/NormalizedCache { public fun ()V public fun (Lcom/apollographql/cache/normalized/api/NormalizedCache;IJ)V @@ -391,11 +408,6 @@ public abstract interface class com/apollographql/cache/normalized/api/ReadOnlyN public abstract fun loadRecords (Ljava/util/Collection;Lcom/apollographql/cache/normalized/api/CacheHeaders;)Ljava/util/Collection; } -public final class com/apollographql/cache/normalized/api/ReceiveDateCacheResolver : com/apollographql/cache/normalized/api/CacheResolver { - public fun (I)V - public fun resolveField (Lcom/apollographql/cache/normalized/api/ResolverContext;)Ljava/lang/Object; -} - public final class com/apollographql/cache/normalized/api/Record : java/util/Map, kotlin/jvm/internal/markers/KMappedMarker { public static final field Companion Lcom/apollographql/cache/normalized/api/Record$Companion; public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/UUID;)V @@ -474,6 +486,12 @@ public final class com/apollographql/cache/normalized/api/ResolverContext { public final fun getVariables ()Lcom/apollographql/apollo/api/Executable$Variables; } +public final class com/apollographql/cache/normalized/api/SchemaCoordinatesMaxAgeProvider : com/apollographql/cache/normalized/api/MaxAgeProvider { + public synthetic fun (Ljava/util/Map;Lkotlin/time/Duration;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/util/Map;Lkotlin/time/Duration;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getMaxAge-LV8wdWc (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)Lkotlin/time/Duration; +} + public final class com/apollographql/cache/normalized/api/TypePolicyCacheKeyGenerator : com/apollographql/cache/normalized/api/CacheKeyGenerator { public static final field INSTANCE Lcom/apollographql/cache/normalized/api/TypePolicyCacheKeyGenerator; public fun cacheKeyForObject (Ljava/util/Map;Lcom/apollographql/cache/normalized/api/CacheKeyGeneratorContext;)Lcom/apollographql/cache/normalized/api/CacheKey; diff --git a/normalized-cache-incubating/api/normalized-cache-incubating.klib.api b/normalized-cache-incubating/api/normalized-cache-incubating.klib.api index 606343db..f11e63c5 100644 --- a/normalized-cache-incubating/api/normalized-cache-incubating.klib.api +++ b/normalized-cache-incubating/api/normalized-cache-incubating.klib.api @@ -43,6 +43,9 @@ abstract interface com.apollographql.cache.normalized.api/EmbeddedFieldsProvider abstract interface com.apollographql.cache.normalized.api/FieldKeyGenerator { // com.apollographql.cache.normalized.api/FieldKeyGenerator|null[0] abstract fun getFieldKey(com.apollographql.cache.normalized.api/FieldKeyContext): kotlin/String // com.apollographql.cache.normalized.api/FieldKeyGenerator.getFieldKey|getFieldKey(com.apollographql.cache.normalized.api.FieldKeyContext){}[0] } +abstract interface com.apollographql.cache.normalized.api/MaxAgeProvider { // com.apollographql.cache.normalized.api/MaxAgeProvider|null[0] + abstract fun getMaxAge(com.apollographql.cache.normalized.api/MaxAgeContext): kotlin.time/Duration? // com.apollographql.cache.normalized.api/MaxAgeProvider.getMaxAge|getMaxAge(com.apollographql.cache.normalized.api.MaxAgeContext){}[0] +} abstract interface com.apollographql.cache.normalized.api/MetadataGenerator { // com.apollographql.cache.normalized.api/MetadataGenerator|null[0] abstract fun metadataForObject(kotlin/Any?, com.apollographql.cache.normalized.api/MetadataGeneratorContext): kotlin.collections/Map // com.apollographql.cache.normalized.api/MetadataGenerator.metadataForObject|metadataForObject(kotlin.Any?;com.apollographql.cache.normalized.api.MetadataGeneratorContext){}[0] } @@ -173,9 +176,9 @@ final class com.apollographql.cache.normalized.api/EmbeddedFieldsContext { // co final val parentType // com.apollographql.cache.normalized.api/EmbeddedFieldsContext.parentType|{}parentType[0] final fun (): com.apollographql.apollo.api/CompiledNamedType // com.apollographql.cache.normalized.api/EmbeddedFieldsContext.parentType.|(){}[0] } -final class com.apollographql.cache.normalized.api/ExpireDateCacheResolver : com.apollographql.cache.normalized.api/CacheResolver { // com.apollographql.cache.normalized.api/ExpireDateCacheResolver|null[0] - constructor () // com.apollographql.cache.normalized.api/ExpireDateCacheResolver.|(){}[0] - final fun resolveField(com.apollographql.cache.normalized.api/ResolverContext): kotlin/Any? // com.apollographql.cache.normalized.api/ExpireDateCacheResolver.resolveField|resolveField(com.apollographql.cache.normalized.api.ResolverContext){}[0] +final class com.apollographql.cache.normalized.api/ExpirationCacheResolver : com.apollographql.cache.normalized.api/CacheResolver { // com.apollographql.cache.normalized.api/ExpirationCacheResolver|null[0] + constructor (com.apollographql.cache.normalized.api/MaxAgeProvider) // com.apollographql.cache.normalized.api/ExpirationCacheResolver.|(com.apollographql.cache.normalized.api.MaxAgeProvider){}[0] + final fun resolveField(com.apollographql.cache.normalized.api/ResolverContext): kotlin/Any? // com.apollographql.cache.normalized.api/ExpirationCacheResolver.resolveField|resolveField(com.apollographql.cache.normalized.api.ResolverContext){}[0] } final class com.apollographql.cache.normalized.api/FieldKeyContext { // com.apollographql.cache.normalized.api/FieldKeyContext|null[0] constructor (kotlin/String, com.apollographql.apollo.api/CompiledField, com.apollographql.apollo.api/Executable.Variables) // com.apollographql.cache.normalized.api/FieldKeyContext.|(kotlin.String;com.apollographql.apollo.api.CompiledField;com.apollographql.apollo.api.Executable.Variables){}[0] @@ -206,6 +209,17 @@ final class com.apollographql.cache.normalized.api/FieldRecordMerger : com.apoll } final fun merge(com.apollographql.cache.normalized.api/Record, com.apollographql.cache.normalized.api/Record): kotlin/Pair> // com.apollographql.cache.normalized.api/FieldRecordMerger.merge|merge(com.apollographql.cache.normalized.api.Record;com.apollographql.cache.normalized.api.Record){}[0] } +final class com.apollographql.cache.normalized.api/GlobalMaxAgeProvider : com.apollographql.cache.normalized.api/MaxAgeProvider { // com.apollographql.cache.normalized.api/GlobalMaxAgeProvider|null[0] + constructor (kotlin.time/Duration) // com.apollographql.cache.normalized.api/GlobalMaxAgeProvider.|(kotlin.time.Duration){}[0] + final fun getMaxAge(com.apollographql.cache.normalized.api/MaxAgeContext): kotlin.time/Duration // com.apollographql.cache.normalized.api/GlobalMaxAgeProvider.getMaxAge|getMaxAge(com.apollographql.cache.normalized.api.MaxAgeContext){}[0] +} +final class com.apollographql.cache.normalized.api/MaxAgeContext { // com.apollographql.cache.normalized.api/MaxAgeContext|null[0] + constructor (com.apollographql.apollo.api/CompiledField, kotlin/String) // com.apollographql.cache.normalized.api/MaxAgeContext.|(com.apollographql.apollo.api.CompiledField;kotlin.String){}[0] + final val field // com.apollographql.cache.normalized.api/MaxAgeContext.field|{}field[0] + final fun (): com.apollographql.apollo.api/CompiledField // com.apollographql.cache.normalized.api/MaxAgeContext.field.|(){}[0] + final val parentType // com.apollographql.cache.normalized.api/MaxAgeContext.parentType|{}parentType[0] + final fun (): kotlin/String // com.apollographql.cache.normalized.api/MaxAgeContext.parentType.|(){}[0] +} final class com.apollographql.cache.normalized.api/MemoryCache : com.apollographql.cache.normalized.api/NormalizedCache { // com.apollographql.cache.normalized.api/MemoryCache|null[0] constructor (com.apollographql.cache.normalized.api/NormalizedCache? = ..., kotlin/Int = ..., kotlin/Long = ...) // com.apollographql.cache.normalized.api/MemoryCache.|(com.apollographql.cache.normalized.api.NormalizedCache?;kotlin.Int;kotlin.Long){}[0] final fun clearAll() // com.apollographql.cache.normalized.api/MemoryCache.clearAll|clearAll(){}[0] @@ -233,10 +247,6 @@ final class com.apollographql.cache.normalized.api/MetadataGeneratorContext { // final val variables // com.apollographql.cache.normalized.api/MetadataGeneratorContext.variables|{}variables[0] final fun (): com.apollographql.apollo.api/Executable.Variables // com.apollographql.cache.normalized.api/MetadataGeneratorContext.variables.|(){}[0] } -final class com.apollographql.cache.normalized.api/ReceiveDateCacheResolver : com.apollographql.cache.normalized.api/CacheResolver { // com.apollographql.cache.normalized.api/ReceiveDateCacheResolver|null[0] - constructor (kotlin/Int) // com.apollographql.cache.normalized.api/ReceiveDateCacheResolver.|(kotlin.Int){}[0] - final fun resolveField(com.apollographql.cache.normalized.api/ResolverContext): kotlin/Any? // com.apollographql.cache.normalized.api/ReceiveDateCacheResolver.resolveField|resolveField(com.apollographql.cache.normalized.api.ResolverContext){}[0] -} final class com.apollographql.cache.normalized.api/Record : kotlin.collections/Map { // com.apollographql.cache.normalized.api/Record|null[0] constructor (kotlin/String, kotlin.collections/Map, com.benasher44.uuid/Uuid? = ...) // com.apollographql.cache.normalized.api/Record.|(kotlin.String;kotlin.collections.Map;com.benasher44.uuid.Uuid?){}[0] constructor (kotlin/String, kotlin.collections/Map, com.benasher44.uuid/Uuid?, kotlin.collections/Map>) // com.apollographql.cache.normalized.api/Record.|(kotlin.String;kotlin.collections.Map;com.benasher44.uuid.Uuid?;kotlin.collections.Map>){}[0] @@ -286,6 +296,10 @@ final class com.apollographql.cache.normalized.api/ResolverContext { // com.apol final val variables // com.apollographql.cache.normalized.api/ResolverContext.variables|{}variables[0] final fun (): com.apollographql.apollo.api/Executable.Variables // com.apollographql.cache.normalized.api/ResolverContext.variables.|(){}[0] } +final class com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider : com.apollographql.cache.normalized.api/MaxAgeProvider { // com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider|null[0] + constructor (kotlin.collections/Map, kotlin.time/Duration? = ...) // com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider.|(kotlin.collections.Map;kotlin.time.Duration?){}[0] + final fun getMaxAge(com.apollographql.cache.normalized.api/MaxAgeContext): kotlin.time/Duration? // com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider.getMaxAge|getMaxAge(com.apollographql.cache.normalized.api.MaxAgeContext){}[0] +} final class com.apollographql.cache.normalized/CacheInfo : com.apollographql.apollo.api/ExecutionContext.Element { // com.apollographql.cache.normalized/CacheInfo|null[0] constructor (kotlin/Long, kotlin/Long, kotlin/Boolean, kotlin/String?, kotlin/String?) // com.apollographql.cache.normalized/CacheInfo.|(kotlin.Long;kotlin.Long;kotlin.Boolean;kotlin.String?;kotlin.String?){}[0] final class Builder { // com.apollographql.cache.normalized/CacheInfo.Builder|null[0] @@ -371,6 +385,7 @@ final fun <#A: kotlin/Any?> (com.apollographql.apollo.api/MutableExecutionOption final fun <#A: kotlin/Any?> (com.apollographql.apollo.api/MutableExecutionOptions<#A>).com.apollographql.cache.normalized/emitCacheMisses(kotlin/Boolean): com.apollographql.apollo.api/MutableExecutionOptions<#A> // com.apollographql.cache.normalized/emitCacheMisses|emitCacheMisses@com.apollographql.apollo.api.MutableExecutionOptions<0:0>(kotlin.Boolean){0§}[0] final fun <#A: kotlin/Any?> (com.apollographql.apollo.api/MutableExecutionOptions<#A>).com.apollographql.cache.normalized/fetchPolicy(com.apollographql.cache.normalized/FetchPolicy): #A // com.apollographql.cache.normalized/fetchPolicy|fetchPolicy@com.apollographql.apollo.api.MutableExecutionOptions<0:0>(com.apollographql.cache.normalized.FetchPolicy){0§}[0] final fun <#A: kotlin/Any?> (com.apollographql.apollo.api/MutableExecutionOptions<#A>).com.apollographql.cache.normalized/fetchPolicyInterceptor(com.apollographql.apollo.interceptor/ApolloInterceptor): #A // com.apollographql.cache.normalized/fetchPolicyInterceptor|fetchPolicyInterceptor@com.apollographql.apollo.api.MutableExecutionOptions<0:0>(com.apollographql.apollo.interceptor.ApolloInterceptor){0§}[0] +final fun <#A: kotlin/Any?> (com.apollographql.apollo.api/MutableExecutionOptions<#A>).com.apollographql.cache.normalized/maxStale(kotlin.time/Duration): #A // com.apollographql.cache.normalized/maxStale|maxStale@com.apollographql.apollo.api.MutableExecutionOptions<0:0>(kotlin.time.Duration){0§}[0] final fun <#A: kotlin/Any?> (com.apollographql.apollo.api/MutableExecutionOptions<#A>).com.apollographql.cache.normalized/memoryCacheOnly(kotlin/Boolean): #A // com.apollographql.cache.normalized/memoryCacheOnly|memoryCacheOnly@com.apollographql.apollo.api.MutableExecutionOptions<0:0>(kotlin.Boolean){0§}[0] final fun <#A: kotlin/Any?> (com.apollographql.apollo.api/MutableExecutionOptions<#A>).com.apollographql.cache.normalized/refetchPolicy(com.apollographql.cache.normalized/FetchPolicy): #A // com.apollographql.cache.normalized/refetchPolicy|refetchPolicy@com.apollographql.apollo.api.MutableExecutionOptions<0:0>(com.apollographql.cache.normalized.FetchPolicy){0§}[0] final fun <#A: kotlin/Any?> (com.apollographql.apollo.api/MutableExecutionOptions<#A>).com.apollographql.cache.normalized/refetchPolicyInterceptor(com.apollographql.apollo.interceptor/ApolloInterceptor): #A // com.apollographql.cache.normalized/refetchPolicyInterceptor|refetchPolicyInterceptor@com.apollographql.apollo.api.MutableExecutionOptions<0:0>(com.apollographql.apollo.interceptor.ApolloInterceptor){0§}[0] From de974f9bd8d07c1d85a05e161bb6dbc8152d8128 Mon Sep 17 00:00:00 2001 From: BoD Date: Tue, 6 Aug 2024 18:44:34 +0200 Subject: [PATCH 5/7] Introduce MaxAge and implement Apollo Server's behavior in SchemaCoordinatesMaxAgeProvider --- .../api/normalized-cache-incubating.api | 31 ++++-- .../api/normalized-cache-incubating.klib.api | 30 ++++-- .../cache/normalized/api/CacheResolver.kt | 30 +++--- .../cache/normalized/api/MaxAgeProvider.kt | 101 +++++++++++++++--- .../api/OperationCacheExtensions.kt | 2 +- .../api/internal/CacheBatchReader.kt | 26 +++-- .../ClientAndServerSideExpirationTest.kt | 3 +- .../kotlin/ClientSideExpirationTest.kt | 8 +- .../kotlin/ServerSideExpirationTest.kt | 10 +- 9 files changed, 179 insertions(+), 62 deletions(-) diff --git a/normalized-cache-incubating/api/normalized-cache-incubating.api b/normalized-cache-incubating/api/normalized-cache-incubating.api index d4bb23a8..0a3c5116 100644 --- a/normalized-cache-incubating/api/normalized-cache-incubating.api +++ b/normalized-cache-incubating/api/normalized-cache-incubating.api @@ -317,17 +317,30 @@ public abstract interface class com/apollographql/cache/normalized/api/FieldReco public final class com/apollographql/cache/normalized/api/GlobalMaxAgeProvider : com/apollographql/cache/normalized/api/MaxAgeProvider { public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getMaxAge-5sfh64U (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)J - public synthetic fun getMaxAge-LV8wdWc (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)Lkotlin/time/Duration; +} + +public abstract interface class com/apollographql/cache/normalized/api/MaxAge { +} + +public final class com/apollographql/cache/normalized/api/MaxAge$Duration : com/apollographql/cache/normalized/api/MaxAge { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDuration-UwyO8pc ()J +} + +public final class com/apollographql/cache/normalized/api/MaxAge$Inherit : com/apollographql/cache/normalized/api/MaxAge { + public static final field INSTANCE Lcom/apollographql/cache/normalized/api/MaxAge$Inherit; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public final class com/apollographql/cache/normalized/api/MaxAgeContext { - public fun (Lcom/apollographql/apollo/api/CompiledField;Ljava/lang/String;)V - public final fun getField ()Lcom/apollographql/apollo/api/CompiledField; - public final fun getParentType ()Ljava/lang/String; + public fun (Ljava/util/List;)V + public final fun getFieldPath ()Ljava/util/List; } public abstract interface class com/apollographql/cache/normalized/api/MaxAgeProvider { - public abstract fun getMaxAge-LV8wdWc (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)Lkotlin/time/Duration; + public abstract fun getMaxAge-5sfh64U (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)J } public final class com/apollographql/cache/normalized/api/MemoryCache : com/apollographql/cache/normalized/api/NormalizedCache { @@ -476,20 +489,20 @@ public final class com/apollographql/cache/normalized/api/RecordMergerKt { } public final class com/apollographql/cache/normalized/api/ResolverContext { - public fun (Lcom/apollographql/apollo/api/CompiledField;Lcom/apollographql/apollo/api/Executable$Variables;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Lcom/apollographql/cache/normalized/api/CacheHeaders;Lcom/apollographql/cache/normalized/api/FieldKeyGenerator;)V + public fun (Lcom/apollographql/apollo/api/CompiledField;Lcom/apollographql/apollo/api/Executable$Variables;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Lcom/apollographql/cache/normalized/api/CacheHeaders;Lcom/apollographql/cache/normalized/api/FieldKeyGenerator;Ljava/util/List;)V public final fun getCacheHeaders ()Lcom/apollographql/cache/normalized/api/CacheHeaders; public final fun getField ()Lcom/apollographql/apollo/api/CompiledField; public final fun getFieldKeyGenerator ()Lcom/apollographql/cache/normalized/api/FieldKeyGenerator; public final fun getParent ()Ljava/util/Map; public final fun getParentKey ()Ljava/lang/String; public final fun getParentType ()Ljava/lang/String; + public final fun getPath ()Ljava/util/List; public final fun getVariables ()Lcom/apollographql/apollo/api/Executable$Variables; } public final class com/apollographql/cache/normalized/api/SchemaCoordinatesMaxAgeProvider : com/apollographql/cache/normalized/api/MaxAgeProvider { - public synthetic fun (Ljava/util/Map;Lkotlin/time/Duration;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/util/Map;Lkotlin/time/Duration;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun getMaxAge-LV8wdWc (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)Lkotlin/time/Duration; + public synthetic fun (Ljava/util/Map;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getMaxAge-5sfh64U (Lcom/apollographql/cache/normalized/api/MaxAgeContext;)J } public final class com/apollographql/cache/normalized/api/TypePolicyCacheKeyGenerator : com/apollographql/cache/normalized/api/CacheKeyGenerator { diff --git a/normalized-cache-incubating/api/normalized-cache-incubating.klib.api b/normalized-cache-incubating/api/normalized-cache-incubating.klib.api index f11e63c5..503b9bc3 100644 --- a/normalized-cache-incubating/api/normalized-cache-incubating.klib.api +++ b/normalized-cache-incubating/api/normalized-cache-incubating.klib.api @@ -44,7 +44,7 @@ abstract interface com.apollographql.cache.normalized.api/FieldKeyGenerator { // abstract fun getFieldKey(com.apollographql.cache.normalized.api/FieldKeyContext): kotlin/String // com.apollographql.cache.normalized.api/FieldKeyGenerator.getFieldKey|getFieldKey(com.apollographql.cache.normalized.api.FieldKeyContext){}[0] } abstract interface com.apollographql.cache.normalized.api/MaxAgeProvider { // com.apollographql.cache.normalized.api/MaxAgeProvider|null[0] - abstract fun getMaxAge(com.apollographql.cache.normalized.api/MaxAgeContext): kotlin.time/Duration? // com.apollographql.cache.normalized.api/MaxAgeProvider.getMaxAge|getMaxAge(com.apollographql.cache.normalized.api.MaxAgeContext){}[0] + abstract fun getMaxAge(com.apollographql.cache.normalized.api/MaxAgeContext): kotlin.time/Duration // com.apollographql.cache.normalized.api/MaxAgeProvider.getMaxAge|getMaxAge(com.apollographql.cache.normalized.api.MaxAgeContext){}[0] } abstract interface com.apollographql.cache.normalized.api/MetadataGenerator { // com.apollographql.cache.normalized.api/MetadataGenerator|null[0] abstract fun metadataForObject(kotlin/Any?, com.apollographql.cache.normalized.api/MetadataGeneratorContext): kotlin.collections/Map // com.apollographql.cache.normalized.api/MetadataGenerator.metadataForObject|metadataForObject(kotlin.Any?;com.apollographql.cache.normalized.api.MetadataGeneratorContext){}[0] @@ -214,11 +214,9 @@ final class com.apollographql.cache.normalized.api/GlobalMaxAgeProvider : com.ap final fun getMaxAge(com.apollographql.cache.normalized.api/MaxAgeContext): kotlin.time/Duration // com.apollographql.cache.normalized.api/GlobalMaxAgeProvider.getMaxAge|getMaxAge(com.apollographql.cache.normalized.api.MaxAgeContext){}[0] } final class com.apollographql.cache.normalized.api/MaxAgeContext { // com.apollographql.cache.normalized.api/MaxAgeContext|null[0] - constructor (com.apollographql.apollo.api/CompiledField, kotlin/String) // com.apollographql.cache.normalized.api/MaxAgeContext.|(com.apollographql.apollo.api.CompiledField;kotlin.String){}[0] - final val field // com.apollographql.cache.normalized.api/MaxAgeContext.field|{}field[0] - final fun (): com.apollographql.apollo.api/CompiledField // com.apollographql.cache.normalized.api/MaxAgeContext.field.|(){}[0] - final val parentType // com.apollographql.cache.normalized.api/MaxAgeContext.parentType|{}parentType[0] - final fun (): kotlin/String // com.apollographql.cache.normalized.api/MaxAgeContext.parentType.|(){}[0] + constructor (kotlin.collections/List) // com.apollographql.cache.normalized.api/MaxAgeContext.|(kotlin.collections.List){}[0] + final val fieldPath // com.apollographql.cache.normalized.api/MaxAgeContext.fieldPath|{}fieldPath[0] + final fun (): kotlin.collections/List // com.apollographql.cache.normalized.api/MaxAgeContext.fieldPath.|(){}[0] } final class com.apollographql.cache.normalized.api/MemoryCache : com.apollographql.cache.normalized.api/NormalizedCache { // com.apollographql.cache.normalized.api/MemoryCache|null[0] constructor (com.apollographql.cache.normalized.api/NormalizedCache? = ..., kotlin/Int = ..., kotlin/Long = ...) // com.apollographql.cache.normalized.api/MemoryCache.|(com.apollographql.cache.normalized.api.NormalizedCache?;kotlin.Int;kotlin.Long){}[0] @@ -280,7 +278,7 @@ final class com.apollographql.cache.normalized.api/Record : kotlin.collections/M final fun asJsReadonlyMapView(): kotlin.js.collections/JsReadonlyMap // com.apollographql.cache.normalized.api/Record.asJsReadonlyMapView|asJsReadonlyMapView(){}[0] } final class com.apollographql.cache.normalized.api/ResolverContext { // com.apollographql.cache.normalized.api/ResolverContext|null[0] - constructor (com.apollographql.apollo.api/CompiledField, com.apollographql.apollo.api/Executable.Variables, kotlin.collections/Map, kotlin/String, kotlin/String, com.apollographql.cache.normalized.api/CacheHeaders, com.apollographql.cache.normalized.api/FieldKeyGenerator) // com.apollographql.cache.normalized.api/ResolverContext.|(com.apollographql.apollo.api.CompiledField;com.apollographql.apollo.api.Executable.Variables;kotlin.collections.Map;kotlin.String;kotlin.String;com.apollographql.cache.normalized.api.CacheHeaders;com.apollographql.cache.normalized.api.FieldKeyGenerator){}[0] + constructor (com.apollographql.apollo.api/CompiledField, com.apollographql.apollo.api/Executable.Variables, kotlin.collections/Map, kotlin/String, kotlin/String, com.apollographql.cache.normalized.api/CacheHeaders, com.apollographql.cache.normalized.api/FieldKeyGenerator, kotlin.collections/List) // com.apollographql.cache.normalized.api/ResolverContext.|(com.apollographql.apollo.api.CompiledField;com.apollographql.apollo.api.Executable.Variables;kotlin.collections.Map;kotlin.String;kotlin.String;com.apollographql.cache.normalized.api.CacheHeaders;com.apollographql.cache.normalized.api.FieldKeyGenerator;kotlin.collections.List){}[0] final val cacheHeaders // com.apollographql.cache.normalized.api/ResolverContext.cacheHeaders|{}cacheHeaders[0] final fun (): com.apollographql.cache.normalized.api/CacheHeaders // com.apollographql.cache.normalized.api/ResolverContext.cacheHeaders.|(){}[0] final val field // com.apollographql.cache.normalized.api/ResolverContext.field|{}field[0] @@ -293,12 +291,14 @@ final class com.apollographql.cache.normalized.api/ResolverContext { // com.apol final fun (): kotlin/String // com.apollographql.cache.normalized.api/ResolverContext.parentKey.|(){}[0] final val parentType // com.apollographql.cache.normalized.api/ResolverContext.parentType|{}parentType[0] final fun (): kotlin/String // com.apollographql.cache.normalized.api/ResolverContext.parentType.|(){}[0] + final val path // com.apollographql.cache.normalized.api/ResolverContext.path|{}path[0] + final fun (): kotlin.collections/List // com.apollographql.cache.normalized.api/ResolverContext.path.|(){}[0] final val variables // com.apollographql.cache.normalized.api/ResolverContext.variables|{}variables[0] final fun (): com.apollographql.apollo.api/Executable.Variables // com.apollographql.cache.normalized.api/ResolverContext.variables.|(){}[0] } final class com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider : com.apollographql.cache.normalized.api/MaxAgeProvider { // com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider|null[0] - constructor (kotlin.collections/Map, kotlin.time/Duration? = ...) // com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider.|(kotlin.collections.Map;kotlin.time.Duration?){}[0] - final fun getMaxAge(com.apollographql.cache.normalized.api/MaxAgeContext): kotlin.time/Duration? // com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider.getMaxAge|getMaxAge(com.apollographql.cache.normalized.api.MaxAgeContext){}[0] + constructor (kotlin.collections/Map, kotlin.time/Duration) // com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider.|(kotlin.collections.Map;kotlin.time.Duration){}[0] + final fun getMaxAge(com.apollographql.cache.normalized.api/MaxAgeContext): kotlin.time/Duration // com.apollographql.cache.normalized.api/SchemaCoordinatesMaxAgeProvider.getMaxAge|getMaxAge(com.apollographql.cache.normalized.api.MaxAgeContext){}[0] } final class com.apollographql.cache.normalized/CacheInfo : com.apollographql.apollo.api/ExecutionContext.Element { // com.apollographql.cache.normalized/CacheInfo|null[0] constructor (kotlin/Long, kotlin/Long, kotlin/Boolean, kotlin/String?, kotlin/String?) // com.apollographql.cache.normalized/CacheInfo.|(kotlin.Long;kotlin.Long;kotlin.Boolean;kotlin.String?;kotlin.String?){}[0] @@ -453,3 +453,15 @@ final val com.apollographql.cache.normalized/cacheInfo // com.apollographql.cach final fun <#A1: com.apollographql.apollo.api/Operation.Data> (com.apollographql.apollo.api/ApolloResponse<#A1>).(): com.apollographql.cache.normalized/CacheInfo? // com.apollographql.cache.normalized/cacheInfo.|@com.apollographql.apollo.api.ApolloResponse<0:0>(){0§}[0] final val com.apollographql.cache.normalized/isFromCache // com.apollographql.cache.normalized/isFromCache|@com.apollographql.apollo.api.ApolloResponse<0:0>{0§}isFromCache[0] final fun <#A1: com.apollographql.apollo.api/Operation.Data> (com.apollographql.apollo.api/ApolloResponse<#A1>).(): kotlin/Boolean // com.apollographql.cache.normalized/isFromCache.|@com.apollographql.apollo.api.ApolloResponse<0:0>(){0§}[0] +sealed interface com.apollographql.cache.normalized.api/MaxAge { // com.apollographql.cache.normalized.api/MaxAge|null[0] + final class Duration : com.apollographql.cache.normalized.api/MaxAge { // com.apollographql.cache.normalized.api/MaxAge.Duration|null[0] + constructor (kotlin.time/Duration) // com.apollographql.cache.normalized.api/MaxAge.Duration.|(kotlin.time.Duration){}[0] + final val duration // com.apollographql.cache.normalized.api/MaxAge.Duration.duration|{}duration[0] + final fun (): kotlin.time/Duration // com.apollographql.cache.normalized.api/MaxAge.Duration.duration.|(){}[0] + } + final object Inherit : com.apollographql.cache.normalized.api/MaxAge { // com.apollographql.cache.normalized.api/MaxAge.Inherit|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.apollographql.cache.normalized.api/MaxAge.Inherit.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.apollographql.cache.normalized.api/MaxAge.Inherit.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.apollographql.cache.normalized.api/MaxAge.Inherit.toString|toString(){}[0] + } +} diff --git a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt index 0e155e71..a7c7a6db 100644 --- a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt +++ b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt @@ -108,6 +108,12 @@ class ResolverContext( * The [FieldKeyGenerator] to use to generate field keys */ val fieldKeyGenerator: FieldKeyGenerator, + + /** + * The path of the field to resolve. + * The first element is the root object, the last element is [field]. + */ + val path: List, ) /** @@ -149,19 +155,17 @@ class ExpirationCacheResolver( val currentDate = currentTimeMillis() / 1000 // Consider the field's max age (client side) - val fieldMaxAge = maxAgeProvider.getMaxAge(MaxAgeContext(field = field, parentType = context.parentType))?.inWholeSeconds - if (fieldMaxAge != null) { - val fieldReceivedDate = context.parent.receivedDate(field.name) - if (fieldReceivedDate != null) { - val fieldAge = currentDate - fieldReceivedDate - val stale = fieldAge - fieldMaxAge - if (stale >= maxStale) { - throw CacheMissException( - context.parentKey, - context.fieldKeyGenerator.getFieldKey(FieldKeyContext(context.parentType, context.field, context.variables)), - true - ) - } + val fieldMaxAge = maxAgeProvider.getMaxAge(MaxAgeContext(context.path)).inWholeSeconds + val fieldReceivedDate = context.parent.receivedDate(field.name) + if (fieldReceivedDate != null) { + val fieldAge = currentDate - fieldReceivedDate + val stale = fieldAge - fieldMaxAge + if (stale >= maxStale) { + throw CacheMissException( + context.parentKey, + context.fieldKeyGenerator.getFieldKey(FieldKeyContext(context.parentType, context.field, context.variables)), + true + ) } } diff --git a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/MaxAgeProvider.kt b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/MaxAgeProvider.kt index 850844e4..0d915833 100644 --- a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/MaxAgeProvider.kt +++ b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/MaxAgeProvider.kt @@ -1,19 +1,22 @@ package com.apollographql.cache.normalized.api import com.apollographql.apollo.api.CompiledField +import com.apollographql.apollo.api.isComposite import kotlin.time.Duration interface MaxAgeProvider { /** - * Returns the max age for the given type and field. - * @return null if no max age is defined for the given type and field. + * Returns the max age for the given field. */ - fun getMaxAge(maxAgeContext: MaxAgeContext): Duration? + fun getMaxAge(maxAgeContext: MaxAgeContext): Duration } class MaxAgeContext( - val field: CompiledField, - val parentType: String, + /** + * The path of the field to get the max age of. + * The first element is the root object, the last element is the field to get the max age of. + */ + val fieldPath: List, ) /** @@ -23,18 +26,90 @@ class GlobalMaxAgeProvider(private val maxAge: Duration) : MaxAgeProvider { override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration = maxAge } +sealed interface MaxAge { + class Duration(val duration: kotlin.time.Duration) : MaxAge + data object Inherit : MaxAge +} + /** * A provider that returns a max age based on [schema coordinates](https://github.com/graphql/graphql-spec/pull/794). - * The given coordinates must be object (e.g. `MyType`) or field (e.g. `MyType.myField`) coordinates. - * If a field matches both field and object coordinates, the field ones are used. + * The given coordinates must be object/interface/union (e.g. `MyType`) or field (e.g. `MyType.myField`) coordinates. + * + * The max age of a field is determined as follows: + * - If the field has a [MaxAge.Duration] max age, return it. + * - Else, if the field has a [MaxAge.Inherit] max age, return the max age of the parent field. + * - Else, if the field's type has a [MaxAge.Duration] max age, return it. + * - Else, if the field's type has a [MaxAge.Inherit] max age, return the max age of the parent field. + * - Else, if the field is a root field, or the field's type is composite, return the default max age. + * - Else, return the max age of the parent field. + * + * Then the lowest of the field's max age and its parent field's max age is returned. */ class SchemaCoordinatesMaxAgeProvider( - private val coordinatesToDurations: Map, - private val defaultMaxAge: Duration? = null, + private val coordinatesToMaxAges: Map, + private val defaultMaxAge: Duration, ) : MaxAgeProvider { - override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration? { - return coordinatesToDurations["${maxAgeContext.parentType}.${maxAgeContext.field.name}"] - ?: coordinatesToDurations[maxAgeContext.parentType] - ?: defaultMaxAge + override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration { + if (maxAgeContext.fieldPath.size == 1) { + // Root field + return defaultMaxAge + } + + val fieldName = maxAgeContext.fieldPath.last().name + val fieldParentTypeName = maxAgeContext.fieldPath[maxAgeContext.fieldPath.lastIndex - 1].type.rawType().name + val fieldCoordinates = "$fieldParentTypeName.$fieldName" + val computedFieldMaxAge = when (val fieldMaxAge = coordinatesToMaxAges[fieldCoordinates]) { + is MaxAge.Duration -> { + fieldMaxAge.duration + } + + is MaxAge.Inherit -> { + getParentMaxAge(maxAgeContext) + } + + null -> { + getTypeMaxAge(maxAgeContext) + } + } + val isRootField = maxAgeContext.fieldPath.size == 2 + return if (isRootField) { + computedFieldMaxAge + } else { + minOf(computedFieldMaxAge, getParentMaxAge(maxAgeContext)) + } + } + + private fun getParentMaxAge(maxAgeContext: MaxAgeContext): Duration = getMaxAge(MaxAgeContext(maxAgeContext.fieldPath.dropLast(1))) + + private fun getTypeMaxAge(maxAgeContext: MaxAgeContext): Duration { + val field = maxAgeContext.fieldPath.last() + val fieldTypeName = field.type.rawType().name + return when (val typeMaxAge = coordinatesToMaxAges[fieldTypeName]) { + is MaxAge.Duration -> { + typeMaxAge.duration + } + + is MaxAge.Inherit -> { + getParentMaxAge(maxAgeContext) + } + + null -> { + getFallbackMaxAge(maxAgeContext) + } + } + } + + // Fallback: + // - root fields have the default maxAge + // - same for fields that return a composite type + // - non root fields that return a leaf type inherit the maxAge of their parent field + private fun getFallbackMaxAge(maxAgeContext: MaxAgeContext): Duration { + val field = maxAgeContext.fieldPath.last() + val isRootField = maxAgeContext.fieldPath.size == 2 + return if (isRootField || field.type.rawType().isComposite()) { + defaultMaxAge + } else { + getParentMaxAge(maxAgeContext) + } } } diff --git a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/OperationCacheExtensions.kt b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/OperationCacheExtensions.kt index f9124fdb..e990ec39 100644 --- a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/OperationCacheExtensions.kt +++ b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/OperationCacheExtensions.kt @@ -107,7 +107,7 @@ private fun Executable.readInternal( variables = variables, rootKey = cacheKey.key, rootSelections = rootField().selections, - rootTypename = rootField().type.rawType().name, + rootField = rootField(), fieldKeyGenerator = fieldKeyGenerator, ).collectData() } diff --git a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/internal/CacheBatchReader.kt b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/internal/CacheBatchReader.kt index eced4955..f0fd3b1d 100644 --- a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/internal/CacheBatchReader.kt +++ b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/internal/CacheBatchReader.kt @@ -28,7 +28,7 @@ internal class CacheBatchReader( private val cacheResolver: CacheResolver, private val cacheHeaders: CacheHeaders, private val rootSelections: List, - private val rootTypename: String, + private val rootField: CompiledField, private val fieldKeyGenerator: FieldKeyGenerator, ) { /** @@ -38,6 +38,7 @@ internal class CacheBatchReader( class PendingReference( val key: String, val path: List, + val fieldPath: List, val selections: List, val parentType: String, ) @@ -91,8 +92,9 @@ internal class CacheBatchReader( PendingReference( key = rootKey, selections = rootSelections, - parentType = rootTypename, - path = emptyList() + parentType = rootField.type.rawType().name, + path = emptyList(), + fieldPath = listOf(rootField), ) ) @@ -129,9 +131,10 @@ internal class CacheBatchReader( parentType = pendingReference.parentType, cacheHeaders = cacheHeaders, fieldKeyGenerator = fieldKeyGenerator, + path = pendingReference.fieldPath + it, ) ) - value.registerCacheKeys(pendingReference.path + it.responseName, it.selections, it.type.rawType().name) + value.registerCacheKeys(pendingReference.path + it.responseName, pendingReference.fieldPath + it, it.selections, it.type.rawType().name) it.responseName to value }.toMap() @@ -146,7 +149,12 @@ internal class CacheBatchReader( /** * The path leading to this value */ - private fun Any?.registerCacheKeys(path: List, selections: List, parentType: String) { + private fun Any?.registerCacheKeys( + path: List, + fieldPath: List, + selections: List, + parentType: String, + ) { when (this) { is CacheKey -> { pendingReferences.add( @@ -154,14 +162,15 @@ internal class CacheBatchReader( key = key, selections = selections, parentType = parentType, - path = path + path = path, + fieldPath = fieldPath, ) ) } is List<*> -> { forEachIndexed { index, value -> - value.registerCacheKeys(path + index, selections, parentType) + value.registerCacheKeys(path + index, fieldPath, selections, parentType) } } @@ -183,9 +192,10 @@ internal class CacheBatchReader( parentType = parentType, cacheHeaders = cacheHeaders, fieldKeyGenerator = fieldKeyGenerator, + path = fieldPath + it, ) ) - value.registerCacheKeys(path + it.responseName, it.selections, it.type.rawType().name) + value.registerCacheKeys(path + it.responseName, fieldPath + it, it.selections, it.type.rawType().name) it.responseName to value }.toMap() diff --git a/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt index f5b4fd2f..1aa3ce4a 100644 --- a/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt @@ -6,6 +6,7 @@ import com.apollographql.apollo.mpp.currentTimeMillis import com.apollographql.apollo.testing.internal.runTest import com.apollographql.cache.normalized.FetchPolicy import com.apollographql.cache.normalized.api.ExpirationCacheResolver +import com.apollographql.cache.normalized.api.MaxAge import com.apollographql.cache.normalized.api.MemoryCacheFactory import com.apollographql.cache.normalized.api.NormalizedCacheFactory import com.apollographql.cache.normalized.api.SchemaCoordinatesMaxAgeProvider @@ -48,7 +49,7 @@ class ClientAndServerSideExpirationTest { cacheResolver = ExpirationCacheResolver( SchemaCoordinatesMaxAgeProvider( mapOf( - "User.email" to 2.seconds, + "User.email" to MaxAge.Duration(2.seconds), ), defaultMaxAge = 20.seconds, ) diff --git a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt index 67802f9a..e797d566 100644 --- a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt @@ -11,6 +11,7 @@ import com.apollographql.cache.normalized.api.CacheHeaders import com.apollographql.cache.normalized.api.DefaultRecordMerger import com.apollographql.cache.normalized.api.ExpirationCacheResolver import com.apollographql.cache.normalized.api.GlobalMaxAgeProvider +import com.apollographql.cache.normalized.api.MaxAge import com.apollographql.cache.normalized.api.MemoryCacheFactory import com.apollographql.cache.normalized.api.NormalizedCacheFactory import com.apollographql.cache.normalized.api.SchemaCoordinatesMaxAgeProvider @@ -104,9 +105,9 @@ class ClientSideExpirationTest { private fun schemaCoordinatesMaxAge(normalizedCacheFactory: NormalizedCacheFactory) = runTest { val maxAgeProvider = SchemaCoordinatesMaxAgeProvider( mapOf( - "User" to 10.seconds, - "User.name" to 5.seconds, - "User.email" to 2.seconds, + "User" to MaxAge.Duration(10.seconds), + "User.name" to MaxAge.Duration(5.seconds), + "User.email" to MaxAge.Duration(2.seconds), ), defaultMaxAge = 20.seconds, ) @@ -180,7 +181,6 @@ class ClientSideExpirationTest { it.merge(records, cacheHeaders(currentTimeMillis() / 1000 - secondsAgo), DefaultRecordMerger) } } - } fun cacheHeaders(receivedDate: Long): CacheHeaders { diff --git a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt index 99411256..9872273d 100644 --- a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt @@ -21,7 +21,6 @@ import com.apollographql.mockserver.MockServer import sqlite.GetUserQuery import kotlin.test.Test import kotlin.test.assertTrue -import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds class ServerSideExpirationTest { @@ -46,9 +45,12 @@ class ServerSideExpirationTest { val client = ApolloClient.Builder() .normalizedCache( normalizedCacheFactory = normalizedCacheFactory, - cacheResolver = ExpirationCacheResolver(object : MaxAgeProvider { - override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration? = null - }) + cacheResolver = ExpirationCacheResolver( + // Can be any value since we don't store the receive date + object : MaxAgeProvider { + override fun getMaxAge(maxAgeContext: MaxAgeContext) = 0.seconds + } + ) ) .storeExpirationDate(true) .serverUrl(mockServer.url()) From 423ab31db797463a446f93f91b290adb19dc5179 Mon Sep 17 00:00:00 2001 From: BoD Date: Wed, 7 Aug 2024 16:32:05 +0200 Subject: [PATCH 6/7] Add tests for SchemaCoordinatesMaxAgeProvider --- .../src/commonMain/graphql/operations.graphql | 81 ++++++++++++ .../src/commonMain/graphql/schema.graphqls | 49 +++++++ .../SchemaCoordinatesMaxAgeProviderTest.kt | 122 ++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt diff --git a/tests/expiration/src/commonMain/graphql/operations.graphql b/tests/expiration/src/commonMain/graphql/operations.graphql index fb7b5984..0de01a4f 100644 --- a/tests/expiration/src/commonMain/graphql/operations.graphql +++ b/tests/expiration/src/commonMain/graphql/operations.graphql @@ -29,3 +29,84 @@ query GetCompany { id } } + +# maxAge: 0 +# Query.book doesn't set a maxAge and it's a root field (default 0). +query GetBookTitle { + book { # 0 + cachedTitle # 30 + } +} + +# maxAge: 60 +# Query.cachedBook has a maxAge of 60, and Book.title is a scalar, so it +# inherits maxAge from its parent by default. +query GetCachedBookTitle { + cachedBook { # 60 + title # inherits + } +} + +# maxAge: 30 +# Query.cachedBook has a maxAge of 60, but Book.cachedTitle has +# a maxAge of 30. +query GetCachedBookCachedTitle { + cachedBook { # 60 + cachedTitle # 30 + } +} + +# maxAge: 40 +# Query.reader has a maxAge of 40. Reader.Book is set to +# inheritMaxAge from its parent, and Book.title is a scalar +# that inherits maxAge from its parent by default. +query GetReaderBookTitle { + reader { # 40 + book { # inherits + title # inherits + } + } +} + +query GetProducts { + products { + id + name + price + colors { + ... on StandardColor { + color + } + ... on CustomColor { + red + green + blue + } + } + } + currentUserId +} + +query GetProduct { + product(id: "1") { + id + name + price + colors { + ... on StandardColor { + color + } + ... on CustomColor { + red + green + blue + } + } + } +} + +query GetNodes { + node(id: "1") { + id + } +} diff --git a/tests/expiration/src/commonMain/graphql/schema.graphqls b/tests/expiration/src/commonMain/graphql/schema.graphqls index bc11b626..67f65e3e 100644 --- a/tests/expiration/src/commonMain/graphql/schema.graphqls +++ b/tests/expiration/src/commonMain/graphql/schema.graphqls @@ -1,6 +1,13 @@ type Query { user: User company: Company + products: [Product] + product(id: ID!): Product + node(id: ID!): Node + book: Book + cachedBook: Book + reader: Reader + currentUserId: String } type User { @@ -12,3 +19,45 @@ type User { type Company { id: ID! } + +interface Node { + id: ID! +} + +type Product implements Node { + id: ID! + name: String! + price: Float! + colors: [ProductColor] +} + +union ProductColor = StandardColor | CustomColor + +type StandardColor { + color: Color +} + +enum Color { + BLACK + WHITE + RED + GREEN + BLUE + ORANGE +} + +type CustomColor { + red: Int! + green: Int! + blue: Int! +} + + +type Book { + title: String + cachedTitle: String +} + +type Reader { + book: Book +} diff --git a/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt b/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt new file mode 100644 index 00000000..9ccff8e3 --- /dev/null +++ b/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt @@ -0,0 +1,122 @@ +package test + +import com.apollographql.apollo.api.CompiledField +import com.apollographql.cache.normalized.api.MaxAge +import com.apollographql.cache.normalized.api.MaxAgeContext +import com.apollographql.cache.normalized.api.SchemaCoordinatesMaxAgeProvider +import sqlite.GetBookTitleQuery +import sqlite.GetCachedBookCachedTitleQuery +import sqlite.GetCachedBookTitleQuery +import sqlite.GetNodesQuery +import sqlite.GetProductQuery +import sqlite.GetProductsQuery +import sqlite.GetReaderBookTitleQuery +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +class SchemaCoordinatesMaxAgeProviderTest { + @Test + fun apolloServerExample() { + // Taken from https://www.apollographql.com/docs/apollo-server/performance/caching/#example-maxage-calculations + val provider = SchemaCoordinatesMaxAgeProvider( + coordinatesToMaxAges = mapOf( + "Query.cachedBook" to MaxAge.Duration(60.seconds), + "Query.reader" to MaxAge.Duration(40.seconds), + "Book.cachedTitle" to MaxAge.Duration(30.seconds), + "Reader.book" to MaxAge.Inherit, + ), + defaultMaxAge = 0.seconds, + ) + + var maxAge = provider.getMaxAge( + MaxAgeContext(GetBookTitleQuery().rootField().path("book", "cachedTitle")) + ) + assertEquals(0.seconds, maxAge) + + maxAge = provider.getMaxAge( + MaxAgeContext(GetCachedBookTitleQuery().rootField().path("cachedBook", "title")) + ) + assertEquals(60.seconds, maxAge) + + maxAge = provider.getMaxAge( + MaxAgeContext(GetCachedBookCachedTitleQuery().rootField().path("cachedBook", "cachedTitle")) + ) + assertEquals(30.seconds, maxAge) + + maxAge = provider.getMaxAge( + MaxAgeContext(GetReaderBookTitleQuery().rootField().path("reader", "book", "title")) + ) + assertEquals(40.seconds, maxAge) + } + + @Test + fun interfaceAndObject() { + val provider = SchemaCoordinatesMaxAgeProvider( + coordinatesToMaxAges = mapOf( + "Product" to MaxAge.Duration(60.seconds), + "Node" to MaxAge.Duration(30.seconds), + ), + defaultMaxAge = 0.seconds, + ) + var maxAge = provider.getMaxAge( + MaxAgeContext(GetProductQuery().rootField().path("product", "id")) + ) + // Product implements Node but it's irrelevant, the type of Query.product is Product so that's what's used + assertEquals(60.seconds, maxAge) + + maxAge = provider.getMaxAge( + MaxAgeContext(GetNodesQuery().rootField().path("node", "id")) + ) + assertEquals(30.seconds, maxAge) + } + + @Test + fun fallbackValue() { + val provider1 = SchemaCoordinatesMaxAgeProvider( + coordinatesToMaxAges = mapOf(), + defaultMaxAge = 12.seconds, + ) + var maxAge = provider1.getMaxAge( + MaxAgeContext(GetProductsQuery().rootField().path("currentUserId")) + ) + // root fields have the default maxAge + assertEquals(12.seconds, maxAge) + + maxAge = provider1.getMaxAge( + MaxAgeContext(GetProductsQuery().rootField().path("products")) + ) + // root fields have the default maxAge + assertEquals(12.seconds, maxAge) + + maxAge = provider1.getMaxAge( + MaxAgeContext(GetProductsQuery().rootField().path("products", "id")) + ) + // non root fields that return a leaf type inherit the maxAge of their parent field + assertEquals(12.seconds, maxAge) + + val provider2 = SchemaCoordinatesMaxAgeProvider( + coordinatesToMaxAges = mapOf( + "Product" to MaxAge.Duration(60.seconds), + ), + defaultMaxAge = 12.seconds, + ) + // non root fields that return a leaf type inherit the maxAge of their parent field + maxAge = provider2.getMaxAge( + MaxAgeContext(GetProductsQuery().rootField().path("products", "id")) + ) + assertEquals(60.seconds, maxAge) + + // fields that return a composite type have the default maxAge + maxAge = provider2.getMaxAge( + MaxAgeContext(GetProductsQuery().rootField().path("products", "colors")) + ) + assertEquals(12.seconds, maxAge) + } +} + +private fun CompiledField.field(name: String): CompiledField = + selections.firstOrNull { (it as CompiledField).name == name } as CompiledField + +private fun CompiledField.path(vararg path: String): List = + path.fold(listOf(this)) { acc, name -> acc + acc.last().field(name) } From 6e919efd109ae8b2fa30b903249d8bd0efb2d306 Mon Sep 17 00:00:00 2001 From: BoD Date: Wed, 7 Aug 2024 17:00:12 +0200 Subject: [PATCH 7/7] Fix broken test --- .../cache/normalized/CacheKeyResolverTest.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/normalized-cache-incubating/src/commonTest/kotlin/com/apollographql/cache/normalized/CacheKeyResolverTest.kt b/normalized-cache-incubating/src/commonTest/kotlin/com/apollographql/cache/normalized/CacheKeyResolverTest.kt index 4106e09a..e82edcd7 100644 --- a/normalized-cache-incubating/src/commonTest/kotlin/com/apollographql/cache/normalized/CacheKeyResolverTest.kt +++ b/normalized-cache-incubating/src/commonTest/kotlin/com/apollographql/cache/normalized/CacheKeyResolverTest.kt @@ -37,7 +37,16 @@ class CacheKeyResolverTest { } private fun resolverContext(field: CompiledField) = - ResolverContext(field, Executable.Variables(emptyMap()), emptyMap(), "", "", CacheHeaders(emptyMap()), DefaultFieldKeyGenerator) + ResolverContext( + field, + Executable.Variables(emptyMap()), + emptyMap(), + "", + "", + CacheHeaders(emptyMap()), + DefaultFieldKeyGenerator, + emptyList() + ) @Test fun verify_cacheKeyForField_called_for_named_composite_field() {