From 6ec9ab7a9c4fbc18792a96620bea2c04a53e5dd6 Mon Sep 17 00:00:00 2001 From: BoD Date: Fri, 23 Aug 2024 12:09:06 +0200 Subject: [PATCH] Add tests for declarative cache control --- gradle/libs.versions.toml | 2 +- .../cache/normalized/api/MaxAgeProvider.kt | 18 ++- settings.gradle.kts | 1 + tests/expiration/build.gradle.kts | 10 +- .../{ => declarative}/operations.graphql | 0 .../graphql/declarative/schema.graphqls | 68 +++++++++++ .../graphql/programmatic/operations.graphql | 112 ++++++++++++++++++ .../{ => programmatic}/schema.graphqls | 0 .../ClientAndServerSideExpirationTest.kt | 6 +- .../kotlin/ClientSideExpirationTest.kt | 99 ++++++++++++++-- .../SchemaCoordinatesMaxAgeProviderTest.kt | 55 +++++++-- .../kotlin/ServerSideExpirationTest.kt | 2 +- tests/settings.gradle.kts | 1 + 13 files changed, 339 insertions(+), 35 deletions(-) rename tests/expiration/src/commonMain/graphql/{ => declarative}/operations.graphql (100%) create mode 100644 tests/expiration/src/commonMain/graphql/declarative/schema.graphqls create mode 100644 tests/expiration/src/commonMain/graphql/programmatic/operations.graphql rename tests/expiration/src/commonMain/graphql/{ => programmatic}/schema.graphqls (100%) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2d6f609..eb93dfef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kotlin-plugin = "2.0.0" android-plugin = "8.2.2" -apollo = "4.0.0" +apollo = "4.0.1-SNAPSHOT" okio = "3.9.0" atomicfu = "0.23.1" # Must be the same version as the one used by apollo-testing-support or native compilation will fail sqldelight = "2.0.1" 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 0d915833..bc3620f5 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 @@ -3,6 +3,7 @@ package com.apollographql.cache.normalized.api import com.apollographql.apollo.api.CompiledField import com.apollographql.apollo.api.isComposite import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds interface MaxAgeProvider { /** @@ -46,7 +47,7 @@ sealed interface MaxAge { * Then the lowest of the field's max age and its parent field's max age is returned. */ class SchemaCoordinatesMaxAgeProvider( - private val coordinatesToMaxAges: Map, + private val maxAges: Map, private val defaultMaxAge: Duration, ) : MaxAgeProvider { override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration { @@ -58,7 +59,7 @@ class SchemaCoordinatesMaxAgeProvider( 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]) { + val computedFieldMaxAge = when (val fieldMaxAge = maxAges[fieldCoordinates]) { is MaxAge.Duration -> { fieldMaxAge.duration } @@ -84,7 +85,7 @@ class SchemaCoordinatesMaxAgeProvider( private fun getTypeMaxAge(maxAgeContext: MaxAgeContext): Duration { val field = maxAgeContext.fieldPath.last() val fieldTypeName = field.type.rawType().name - return when (val typeMaxAge = coordinatesToMaxAges[fieldTypeName]) { + return when (val typeMaxAge = maxAges[fieldTypeName]) { is MaxAge.Duration -> { typeMaxAge.duration } @@ -113,3 +114,14 @@ class SchemaCoordinatesMaxAgeProvider( } } } + +fun SchemaCoordinatesMaxAgeProvider(maxAges: Map, defaultMaxAge: Duration): MaxAgeProvider { + val mappedMaxAges = maxAges.mapValues { + if (it.value == -1) { + MaxAge.Inherit + } else { + MaxAge.Duration(it.value.seconds) + } + } + return SchemaCoordinatesMaxAgeProvider(mappedMaxAges, defaultMaxAge) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index d13d8bb5..51113f35 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,6 @@ pluginManagement { listOf(repositories, dependencyResolutionManagement.repositories).forEach { + it.mavenLocal() it.mavenCentral() it.google() } diff --git a/tests/expiration/build.gradle.kts b/tests/expiration/build.gradle.kts index f662eb8e..7bf8d82e 100644 --- a/tests/expiration/build.gradle.kts +++ b/tests/expiration/build.gradle.kts @@ -63,7 +63,13 @@ kotlin { } apollo { - service("service") { - packageName.set("sqlite") + service("programmatic") { + packageName.set("programmatic") + srcDir("src/commonMain/graphql/programmatic") + } + + service("declarative") { + packageName.set("declarative") + srcDir("src/commonMain/graphql/declarative") } } diff --git a/tests/expiration/src/commonMain/graphql/operations.graphql b/tests/expiration/src/commonMain/graphql/declarative/operations.graphql similarity index 100% rename from tests/expiration/src/commonMain/graphql/operations.graphql rename to tests/expiration/src/commonMain/graphql/declarative/operations.graphql diff --git a/tests/expiration/src/commonMain/graphql/declarative/schema.graphqls b/tests/expiration/src/commonMain/graphql/declarative/schema.graphqls new file mode 100644 index 00000000..264ec286 --- /dev/null +++ b/tests/expiration/src/commonMain/graphql/declarative/schema.graphqls @@ -0,0 +1,68 @@ +extend schema @link( + url: "https://specs.apollo.dev/cache/v0.1", + import: ["@cacheControl", "@cacheControlField"] +) + +type Query { + user: User + company: Company + products: [Product] + product(id: ID!): Product + node(id: ID!): Node + book: Book + cachedBook: Book @cacheControl(maxAge: 60) + reader: Reader @cacheControl(maxAge: 40) + currentUserId: String +} + +type User @cacheControl(maxAge: 10) { + name: String! @cacheControl(maxAge: 5) + email: String! @cacheControl(maxAge: 2) + admin: Boolean +} + +type Company { + id: ID! +} + +interface Node @cacheControl(maxAge: 30) { + id: ID! +} + +type Product implements Node @cacheControl(maxAge: 60) { + 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 @cacheControl(maxAge: 30) +} + +type Reader { + book: Book @cacheControl(inheritMaxAge: true) +} diff --git a/tests/expiration/src/commonMain/graphql/programmatic/operations.graphql b/tests/expiration/src/commonMain/graphql/programmatic/operations.graphql new file mode 100644 index 00000000..0de01a4f --- /dev/null +++ b/tests/expiration/src/commonMain/graphql/programmatic/operations.graphql @@ -0,0 +1,112 @@ +query GetUser { + user { + name + email + admin + } +} + +query GetUserAdmin { + user { + admin + } +} + +query GetUserEmail { + user { + email + } +} + +query GetUserName { + user { + name + } +} + +query GetCompany { + company { + 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/programmatic/schema.graphqls similarity index 100% rename from tests/expiration/src/commonMain/graphql/schema.graphqls rename to tests/expiration/src/commonMain/graphql/programmatic/schema.graphqls diff --git a/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt index 1aa3ce4a..d058e793 100644 --- a/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ClientAndServerSideExpirationTest.kt @@ -18,9 +18,9 @@ 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 programmatic.GetUserEmailQuery +import programmatic.GetUserNameQuery +import programmatic.GetUserQuery import kotlin.test.Test import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds diff --git a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt index e797d566..dc7d605e 100644 --- a/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ClientSideExpirationTest.kt @@ -22,11 +22,11 @@ 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.GetCompanyQuery -import sqlite.GetUserAdminQuery -import sqlite.GetUserEmailQuery -import sqlite.GetUserNameQuery -import sqlite.GetUserQuery +import programmatic.GetCompanyQuery +import programmatic.GetUserAdminQuery +import programmatic.GetUserEmailQuery +import programmatic.GetUserNameQuery +import programmatic.GetUserQuery import kotlin.test.Test import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds @@ -48,18 +48,33 @@ class ClientSideExpirationTest { } @Test - fun schemaCoordinatesMaxAgeMemoryCache() { - schemaCoordinatesMaxAge(MemoryCacheFactory()) + fun programmaticMaxAgeMemoryCache() { + programmaticMaxAge(MemoryCacheFactory()) } @Test - fun schemaCoordinatesMaxAgeSqlCache() { - schemaCoordinatesMaxAge(SqlNormalizedCacheFactory()) + fun programmaticMaxAgeSqlCache() { + programmaticMaxAge(SqlNormalizedCacheFactory()) } @Test - fun schemaCoordinatesMaxAgeChainedCache() { - schemaCoordinatesMaxAge(MemoryCacheFactory().chain(SqlNormalizedCacheFactory())) + fun programmaticMaxAgeChainedCache() { + programmaticMaxAge(MemoryCacheFactory().chain(SqlNormalizedCacheFactory())) + } + + @Test + fun declarativeMaxAgeMemoryCache() { + declarativeMaxAge(MemoryCacheFactory()) + } + + @Test + fun declarativeMaxAgeSqlCache() { + declarativeMaxAge(SqlNormalizedCacheFactory()) + } + + @Test + fun declarativeMaxAgeChainedCache() { + declarativeMaxAge(MemoryCacheFactory().chain(SqlNormalizedCacheFactory())) } @@ -102,7 +117,7 @@ class ClientSideExpirationTest { assertTrue(response2.data?.user?.name == "John") } - private fun schemaCoordinatesMaxAge(normalizedCacheFactory: NormalizedCacheFactory) = runTest { + private fun programmaticMaxAge(normalizedCacheFactory: NormalizedCacheFactory) = runTest { val maxAgeProvider = SchemaCoordinatesMaxAgeProvider( mapOf( "User" to MaxAge.Duration(10.seconds), @@ -174,6 +189,66 @@ class ClientSideExpirationTest { } } + private fun declarativeMaxAge(normalizedCacheFactory: NormalizedCacheFactory) = runTest { + val maxAgeProvider = SchemaCoordinatesMaxAgeProvider( + declarative.cache.Cache.maxAges, + 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(declarative.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(declarative.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(declarative.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(declarative.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(declarative.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(declarative.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 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 diff --git a/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt b/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt index 9ccff8e3..01a664cc 100644 --- a/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt +++ b/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt @@ -4,23 +4,24 @@ 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 declarative.cache.Cache +import programmatic.GetBookTitleQuery +import programmatic.GetCachedBookCachedTitleQuery +import programmatic.GetCachedBookTitleQuery +import programmatic.GetNodesQuery +import programmatic.GetProductQuery +import programmatic.GetProductsQuery +import programmatic.GetReaderBookTitleQuery import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds class SchemaCoordinatesMaxAgeProviderTest { @Test - fun apolloServerExample() { + fun programmaticApolloServerExample() { // Taken from https://www.apollographql.com/docs/apollo-server/performance/caching/#example-maxage-calculations val provider = SchemaCoordinatesMaxAgeProvider( - coordinatesToMaxAges = mapOf( + maxAges = mapOf( "Query.cachedBook" to MaxAge.Duration(60.seconds), "Query.reader" to MaxAge.Duration(40.seconds), "Book.cachedTitle" to MaxAge.Duration(30.seconds), @@ -50,10 +51,38 @@ class SchemaCoordinatesMaxAgeProviderTest { assertEquals(40.seconds, maxAge) } + @Test + fun declarativeApolloServerExample() { + val provider = SchemaCoordinatesMaxAgeProvider( + maxAges = Cache.maxAges, + defaultMaxAge = 0.seconds, + ) + + var maxAge = provider.getMaxAge( + MaxAgeContext(declarative.GetBookTitleQuery().rootField().path("book", "cachedTitle")) + ) + assertEquals(0.seconds, maxAge) + + maxAge = provider.getMaxAge( + MaxAgeContext(declarative.GetCachedBookTitleQuery().rootField().path("cachedBook", "title")) + ) + assertEquals(60.seconds, maxAge) + + maxAge = provider.getMaxAge( + MaxAgeContext(declarative.GetCachedBookCachedTitleQuery().rootField().path("cachedBook", "cachedTitle")) + ) + assertEquals(30.seconds, maxAge) + + maxAge = provider.getMaxAge( + MaxAgeContext(declarative.GetReaderBookTitleQuery().rootField().path("reader", "book", "title")) + ) + assertEquals(40.seconds, maxAge) + } + @Test fun interfaceAndObject() { val provider = SchemaCoordinatesMaxAgeProvider( - coordinatesToMaxAges = mapOf( + maxAges = mapOf( "Product" to MaxAge.Duration(60.seconds), "Node" to MaxAge.Duration(30.seconds), ), @@ -74,7 +103,7 @@ class SchemaCoordinatesMaxAgeProviderTest { @Test fun fallbackValue() { val provider1 = SchemaCoordinatesMaxAgeProvider( - coordinatesToMaxAges = mapOf(), + maxAges = mapOf(), defaultMaxAge = 12.seconds, ) var maxAge = provider1.getMaxAge( @@ -96,7 +125,7 @@ class SchemaCoordinatesMaxAgeProviderTest { assertEquals(12.seconds, maxAge) val provider2 = SchemaCoordinatesMaxAgeProvider( - coordinatesToMaxAges = mapOf( + maxAges = mapOf( "Product" to MaxAge.Duration(60.seconds), ), defaultMaxAge = 12.seconds, @@ -118,5 +147,5 @@ class SchemaCoordinatesMaxAgeProviderTest { private fun CompiledField.field(name: String): CompiledField = selections.firstOrNull { (it as CompiledField).name == name } as CompiledField -private fun CompiledField.path(vararg path: String): List = +fun CompiledField.path(vararg path: String): List = path.fold(listOf(this)) { acc, name -> acc + acc.last().field(name) } diff --git a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt index 9872273d..cd5f1a13 100644 --- a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt @@ -18,7 +18,7 @@ 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 programmatic.GetUserQuery import kotlin.test.Test import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds diff --git a/tests/settings.gradle.kts b/tests/settings.gradle.kts index 2cc7cc1a..002a82cd 100644 --- a/tests/settings.gradle.kts +++ b/tests/settings.gradle.kts @@ -1,6 +1,7 @@ pluginManagement { listOf(repositories, dependencyResolutionManagement.repositories).forEach { it.apply { + mavenLocal() mavenCentral() } }