Skip to content

Different roots for Query, Mutation, Subscription #124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Next version (unreleased)

PUT_CHANGELOG_HERE
- Records are now rooted per operation type (QUERY_ROOT, MUTATION_ROOT, SUBSCRIPTION_ROOT) (#109)

# Version 0.0.8
_2025-03-28_
Expand Down
2 changes: 1 addition & 1 deletion Writerside/topics/migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ store.writeOperation(operation, data).also { store.publish(it) }
### Other changes

- `readFragment()` now returns a `ReadResult<D>` (it previously returned a `<D>`). This allows for surfacing metadata associated to the returned data, e.g. staleness.

- Records are now rooted per operation type (`QUERY_ROOT`, `MUTATION_ROOT`, `SUBSCRIPTION_ROOT`), when previously these were all at the same level, which could cause conflicts.

## CacheResolver, CacheKeyResolver

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,14 +240,15 @@ public final class com/apollographql/cache/normalized/api/CacheKey {
public final fun getKey ()Ljava/lang/String;
public fun hashCode ()I
public static fun hashCode-impl (Ljava/lang/String;)I
public static final fun rootKey-mqw0cJ0 ()Ljava/lang/String;
public fun toString ()Ljava/lang/String;
public static fun toString-impl (Ljava/lang/String;)Ljava/lang/String;
public final synthetic fun unbox-impl ()Ljava/lang/String;
}

public final class com/apollographql/cache/normalized/api/CacheKey$Companion {
public final fun rootKey-mqw0cJ0 ()Ljava/lang/String;
public final fun getMUTATION_ROOT-mqw0cJ0 ()Ljava/lang/String;
public final fun getQUERY_ROOT-mqw0cJ0 ()Ljava/lang/String;
public final fun getSUBSCRIPTION_ROOT-mqw0cJ0 ()Ljava/lang/String;
}

public abstract interface class com/apollographql/cache/normalized/api/CacheKeyGenerator {
Expand All @@ -260,10 +261,6 @@ public final class com/apollographql/cache/normalized/api/CacheKeyGeneratorConte
public final fun getVariables ()Lcom/apollographql/apollo/api/Executable$Variables;
}

public final class com/apollographql/cache/normalized/api/CacheKeyKt {
public static final fun isRootKey-pWl1Des (Ljava/lang/String;)Z
}

public abstract class com/apollographql/cache/normalized/api/CacheKeyResolver : com/apollographql/cache/normalized/api/CacheResolver {
public fun <init> ()V
public abstract fun cacheKeyForField-fLoEQYY (Lcom/apollographql/cache/normalized/api/ResolverContext;)Ljava/lang/String;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,12 @@ final value class com.apollographql.cache.normalized.api/CacheKey { // com.apoll
final fun toString(): kotlin/String // com.apollographql.cache.normalized.api/CacheKey.toString|toString(){}[0]

final object Companion { // com.apollographql.cache.normalized.api/CacheKey.Companion|null[0]
final fun rootKey(): com.apollographql.cache.normalized.api/CacheKey // com.apollographql.cache.normalized.api/CacheKey.Companion.rootKey|rootKey(){}[0]
final val MUTATION_ROOT // com.apollographql.cache.normalized.api/CacheKey.Companion.MUTATION_ROOT|{}MUTATION_ROOT[0]
final fun <get-MUTATION_ROOT>(): com.apollographql.cache.normalized.api/CacheKey // com.apollographql.cache.normalized.api/CacheKey.Companion.MUTATION_ROOT.<get-MUTATION_ROOT>|<get-MUTATION_ROOT>(){}[0]
final val QUERY_ROOT // com.apollographql.cache.normalized.api/CacheKey.Companion.QUERY_ROOT|{}QUERY_ROOT[0]
final fun <get-QUERY_ROOT>(): com.apollographql.cache.normalized.api/CacheKey // com.apollographql.cache.normalized.api/CacheKey.Companion.QUERY_ROOT.<get-QUERY_ROOT>|<get-QUERY_ROOT>(){}[0]
final val SUBSCRIPTION_ROOT // com.apollographql.cache.normalized.api/CacheKey.Companion.SUBSCRIPTION_ROOT|{}SUBSCRIPTION_ROOT[0]
final fun <get-SUBSCRIPTION_ROOT>(): com.apollographql.cache.normalized.api/CacheKey // com.apollographql.cache.normalized.api/CacheKey.Companion.SUBSCRIPTION_ROOT.<get-SUBSCRIPTION_ROOT>|<get-SUBSCRIPTION_ROOT>(){}[0]
}
}

Expand Down Expand Up @@ -567,7 +572,6 @@ final val com.apollographql.cache.normalized/isFromCache // com.apollographql.ca
final fun (com.apollographql.apollo/ApolloClient.Builder).com.apollographql.cache.normalized/logCacheMisses(kotlin/Function1<kotlin/String, kotlin/Unit> = ...): com.apollographql.apollo/ApolloClient.Builder // com.apollographql.cache.normalized/logCacheMisses|logCacheMisses@com.apollographql.apollo.ApolloClient.Builder(kotlin.Function1<kotlin.String,kotlin.Unit>){}[0]
final fun (com.apollographql.apollo/ApolloClient.Builder).com.apollographql.cache.normalized/normalizedCache(com.apollographql.cache.normalized.api/NormalizedCacheFactory, com.apollographql.cache.normalized.api/CacheKeyGenerator = ..., com.apollographql.cache.normalized.api/MetadataGenerator = ..., com.apollographql.cache.normalized.api/CacheResolver = ..., com.apollographql.cache.normalized.api/RecordMerger = ..., com.apollographql.cache.normalized.api/FieldKeyGenerator = ..., com.apollographql.cache.normalized.api/EmbeddedFieldsProvider = ..., com.apollographql.cache.normalized.api/MaxAgeProvider = ..., kotlin/Boolean = ...): com.apollographql.apollo/ApolloClient.Builder // com.apollographql.cache.normalized/normalizedCache|normalizedCache@com.apollographql.apollo.ApolloClient.Builder(com.apollographql.cache.normalized.api.NormalizedCacheFactory;com.apollographql.cache.normalized.api.CacheKeyGenerator;com.apollographql.cache.normalized.api.MetadataGenerator;com.apollographql.cache.normalized.api.CacheResolver;com.apollographql.cache.normalized.api.RecordMerger;com.apollographql.cache.normalized.api.FieldKeyGenerator;com.apollographql.cache.normalized.api.EmbeddedFieldsProvider;com.apollographql.cache.normalized.api.MaxAgeProvider;kotlin.Boolean){}[0]
final fun (com.apollographql.apollo/ApolloClient.Builder).com.apollographql.cache.normalized/store(com.apollographql.cache.normalized/ApolloStore, kotlin/Boolean = ...): com.apollographql.apollo/ApolloClient.Builder // com.apollographql.cache.normalized/store|store@com.apollographql.apollo.ApolloClient.Builder(com.apollographql.cache.normalized.ApolloStore;kotlin.Boolean){}[0]
final fun (com.apollographql.cache.normalized.api/CacheKey).com.apollographql.cache.normalized.api/isRootKey(): kotlin/Boolean // com.apollographql.cache.normalized.api/isRootKey|isRootKey@com.apollographql.cache.normalized.api.CacheKey(){}[0]
final fun (com.apollographql.cache.normalized.api/NormalizedCache).com.apollographql.cache.normalized/allRecords(): kotlin.collections/Map<com.apollographql.cache.normalized.api/CacheKey, com.apollographql.cache.normalized.api/Record> // com.apollographql.cache.normalized/allRecords|allRecords@com.apollographql.cache.normalized.api.NormalizedCache(){}[0]
final fun (com.apollographql.cache.normalized.api/NormalizedCache).com.apollographql.cache.normalized/garbageCollect(com.apollographql.cache.normalized.api/MaxAgeProvider, kotlin.time/Duration = ...): com.apollographql.cache.normalized/GarbageCollectResult // com.apollographql.cache.normalized/garbageCollect|garbageCollect@com.apollographql.cache.normalized.api.NormalizedCache(com.apollographql.cache.normalized.api.MaxAgeProvider;kotlin.time.Duration){}[0]
final fun (com.apollographql.cache.normalized.api/NormalizedCache).com.apollographql.cache.normalized/removeDanglingReferences(): com.apollographql.cache.normalized/RemovedFieldsAndRecords // com.apollographql.cache.normalized/removeDanglingReferences|removeDanglingReferences@com.apollographql.cache.normalized.api.NormalizedCache(){}[0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ interface ApolloStore {
fun <D : Executable.Data> normalize(
executable: Executable<D>,
dataWithErrors: DataWithErrors,
rootKey: CacheKey = CacheKey.rootKey(),
rootKey: CacheKey = CacheKey.QUERY_ROOT,
customScalarAdapters: CustomScalarAdapters = CustomScalarAdapters.Empty,
): Map<CacheKey, Record>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ fun Map<CacheKey, Record>.getReachableCacheKeys(): Set<CacheKey> {
}

return mutableSetOf<CacheKey>().also { reachableCacheKeys ->
getReachableCacheKeys(listOf(CacheKey.rootKey()), reachableCacheKeys)
getReachableCacheKeys(listOf(CacheKey.QUERY_ROOT, CacheKey.MUTATION_ROOT, CacheKey.SUBSCRIPTION_ROOT), reachableCacheKeys)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.apollographql.cache.normalized.api

import com.apollographql.apollo.api.Mutation
import com.apollographql.apollo.api.Operation
import com.apollographql.apollo.api.Query
import com.apollographql.apollo.api.Subscription
import kotlin.jvm.JvmInline
import kotlin.jvm.JvmStatic

/**
* A [CacheKey] identifies an object in the cache.
Expand Down Expand Up @@ -42,17 +45,14 @@ value class CacheKey(
override fun toString() = "CacheKey(${keyToString()})"

companion object {
private val ROOT_CACHE_KEY = CacheKey("QUERY_ROOT")

@JvmStatic
fun rootKey(): CacheKey {
return ROOT_CACHE_KEY
}
val QUERY_ROOT = CacheKey("QUERY_ROOT")
val MUTATION_ROOT = CacheKey("MUTATION_ROOT")
val SUBSCRIPTION_ROOT = CacheKey("SUBSCRIPTION_ROOT")
}
}

fun CacheKey.isRootKey(): Boolean {
return this == CacheKey.rootKey()
internal fun CacheKey.isRootKey(): Boolean {
return this == CacheKey.QUERY_ROOT || this == CacheKey.MUTATION_ROOT || this == CacheKey.SUBSCRIPTION_ROOT
}

internal fun CacheKey.fieldKey(fieldName: String): String {
Expand All @@ -66,3 +66,10 @@ internal fun CacheKey.append(vararg keys: String): CacheKey {
}
return cacheKey
}

internal fun Operation<*>.rootKey() = when (this) {
is Query -> CacheKey.QUERY_ROOT
is Mutation -> CacheKey.MUTATION_ROOT
is Subscription -> CacheKey.SUBSCRIPTION_ROOT
else -> throw IllegalArgumentException("Unknown operation type: ${this::class}")
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.apollographql.cache.normalized.api.FieldKeyGenerator
import com.apollographql.cache.normalized.api.ReadOnlyNormalizedCache
import com.apollographql.cache.normalized.api.Record
import com.apollographql.cache.normalized.api.ResolverContext
import com.apollographql.cache.normalized.api.isRootKey
import com.apollographql.cache.normalized.cacheMissException
import kotlin.jvm.JvmSuppressWildcards

Expand Down Expand Up @@ -121,7 +122,7 @@ internal class CacheBatchReader(
copy.forEach { pendingReference ->
var record = records[pendingReference.key]
if (record == null) {
if (pendingReference.key == CacheKey.rootKey()) {
if (pendingReference.key.isRootKey()) {
// This happens the very first time we read the cache
record = Record(pendingReference.key, emptyMap())
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.apollographql.cache.normalized.api.NormalizedCacheFactory
import com.apollographql.cache.normalized.api.Record
import com.apollographql.cache.normalized.api.RecordMerger
import com.apollographql.cache.normalized.api.propagateErrors
import com.apollographql.cache.normalized.api.rootKey
import com.apollographql.cache.normalized.api.withErrors
import com.apollographql.cache.normalized.cacheHeaders
import com.apollographql.cache.normalized.cacheInfo
Expand Down Expand Up @@ -132,7 +133,7 @@ internal class DefaultApolloStore(
cacheHeaders = cacheHeaders,
cacheResolver = cacheResolver,
variables = variables,
rootKey = CacheKey.rootKey(),
rootKey = operation.rootKey(),
rootSelections = operation.rootField().selections,
rootField = operation.rootField(),
fieldKeyGenerator = fieldKeyGenerator,
Expand Down Expand Up @@ -225,6 +226,7 @@ internal class DefaultApolloStore(
val records = normalize(
executable = operation,
dataWithErrors = dataWithErrors,
rootKey = operation.rootKey(),
customScalarAdapters = customScalarAdapters,
).values.toSet()
return cache.merge(records, cacheHeaders, recordMerger)
Expand Down Expand Up @@ -257,6 +259,7 @@ internal class DefaultApolloStore(
val records = normalize(
executable = operation,
dataWithErrors = dataWithErrors,
rootKey = operation.rootKey(),
customScalarAdapters = customScalarAdapters,
).values.map { record ->
Record(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import com.apollographql.cache.normalized.api.MetadataGeneratorContext
import com.apollographql.cache.normalized.api.Record
import com.apollographql.cache.normalized.api.TypePolicyCacheKeyGenerator
import com.apollographql.cache.normalized.api.append
import com.apollographql.cache.normalized.api.isRootKey
import com.apollographql.cache.normalized.api.toMaxAgeField
import com.apollographql.cache.normalized.api.withErrors
import kotlin.time.Duration
Expand Down Expand Up @@ -107,8 +106,9 @@ internal class Normalizer(

val fieldKey = fieldKeyGenerator.getFieldKey(FieldKeyContext(parentType.name, mergedField, variables))

val base = if (key.isRootKey()) {
// If we're at the root level, skip `QUERY_ROOT` altogether to save a few bytes
val base = if (key == CacheKey.QUERY_ROOT) {
// If we're at the query root level, skip `QUERY_ROOT` altogether to save a few bytes.
// For mutations and subscriptions, keep it.
null
} else {
key
Expand Down Expand Up @@ -194,6 +194,7 @@ internal class Normalizer(
embeddedFields: List<String>,
): Any? {
val field = fieldPath.last()

/**
* Remove the NotNull decoration if needed
*/
Expand Down Expand Up @@ -281,7 +282,7 @@ internal class Normalizer(
*/
fun <D : Executable.Data> D.normalized(
executable: Executable<D>,
rootKey: CacheKey = CacheKey.rootKey(),
rootKey: CacheKey = CacheKey.QUERY_ROOT,
customScalarAdapters: CustomScalarAdapters = CustomScalarAdapters.Empty,
cacheKeyGenerator: CacheKeyGenerator = TypePolicyCacheKeyGenerator,
metadataGenerator: MetadataGenerator = EmptyMetadataGenerator,
Expand All @@ -298,7 +299,7 @@ fun <D : Executable.Data> D.normalized(
*/
fun <D : Executable.Data> DataWithErrors.normalized(
executable: Executable<D>,
rootKey: CacheKey = CacheKey.rootKey(),
rootKey: CacheKey = CacheKey.QUERY_ROOT,
customScalarAdapters: CustomScalarAdapters = CustomScalarAdapters.Empty,
cacheKeyGenerator: CacheKeyGenerator = TypePolicyCacheKeyGenerator,
metadataGenerator: MetadataGenerator = EmptyMetadataGenerator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.apollographql.apollo.interceptor.ApolloInterceptor
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
import com.apollographql.cache.normalized.ApolloStore
import com.apollographql.cache.normalized.ApolloStoreInterceptor
import com.apollographql.cache.normalized.api.CacheKey
import com.apollographql.cache.normalized.api.dependentKeys
import com.apollographql.cache.normalized.api.withErrors
import com.apollographql.cache.normalized.watchContext
Expand Down Expand Up @@ -39,7 +40,12 @@ internal class WatcherInterceptor(val store: ApolloStore) : ApolloInterceptor, A
var watchedKeys: Set<String>? =
watchContext.data?.let { data ->
val dataWithErrors = (data as D).withErrors(request.operation, null, customScalarAdapters)
store.normalize(request.operation, dataWithErrors, customScalarAdapters = customScalarAdapters).values.dependentKeys()
store.normalize(
executable = request.operation,
dataWithErrors = dataWithErrors,
rootKey = CacheKey.QUERY_ROOT,
customScalarAdapters = customScalarAdapters,
).values.dependentKeys()
}

return (store.changedKeys as SharedFlow<Any>)
Expand All @@ -60,7 +66,12 @@ internal class WatcherInterceptor(val store: ApolloStore) : ApolloInterceptor, A
if (response.data != null) {
val dataWithErrors = response.data!!.withErrors(request.operation, response.errors, customScalarAdapters)
watchedKeys =
store.normalize(request.operation, dataWithErrors, customScalarAdapters = customScalarAdapters).values.dependentKeys()
store.normalize(
executable = request.operation,
dataWithErrors = dataWithErrors,
rootKey = CacheKey.QUERY_ROOT,
customScalarAdapters = customScalarAdapters,
).values.dependentKeys()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ internal object RecordSerializer {
buffer.writeMap(record.fields)
buffer._writeInt(record.metadata.size)
for ((k, v) in record.metadata.mapKeys { (k, _) -> knownMetadataKeys[k] ?: k }) {
buffer.writeString(k)
buffer.writeString(k.shortenCacheKey())
buffer.writeMap(v)
}
return buffer.readByteArray()
Expand All @@ -32,7 +32,7 @@ internal object RecordSerializer {
val metadataSize = buffer._readInt()
val metadata = HashMap<String, Map<String, ApolloJsonElement>>(metadataSize).apply {
repeat(metadataSize) {
val k = buffer.readString()
val k = buffer.readString().expandCacheKey()
val v = buffer.readMap()
put(k, v)
}
Expand Down Expand Up @@ -174,7 +174,7 @@ internal object RecordSerializer {

is CacheKey -> {
writeByte(CACHE_KEY)
writeString(value.key)
writeString(value.key.shortenCacheKey())
}

is List<*> -> {
Expand Down Expand Up @@ -242,7 +242,7 @@ internal object RecordSerializer {
BOOLEAN_TRUE -> true
BOOLEAN_FALSE -> false
CACHE_KEY -> {
CacheKey(readString())
CacheKey(readString().expandCacheKey())
}

LIST -> {
Expand Down Expand Up @@ -321,4 +321,31 @@ internal object RecordSerializer {
ApolloCacheHeaders.EXPIRATION_DATE to "1",
)
private val knownMetadataKeysInverted = knownMetadataKeys.entries.associate { (k, v) -> v to k }

private val mutationPrefixLong = CacheKey.MUTATION_ROOT.key + "."
private val subscriptionPrefixLong = CacheKey.SUBSCRIPTION_ROOT.key + "."

// Use non printable characters to reduce likelihood of collisions with legitimate cache keys
private const val mutationPrefixShort = "\u0001"
private const val subscriptionPrefixShort = "\u0002"

private fun String.shortenCacheKey(): String {
return if (startsWith(mutationPrefixLong)) {
replaceFirst(mutationPrefixLong, mutationPrefixShort)
} else if (startsWith(subscriptionPrefixLong)) {
replaceFirst(subscriptionPrefixLong, subscriptionPrefixShort)
} else {
this
}
}

private fun String.expandCacheKey(): String {
return if (startsWith(mutationPrefixShort)) {
replaceFirst(mutationPrefixShort, mutationPrefixLong)
} else if (startsWith(subscriptionPrefixShort)) {
replaceFirst(subscriptionPrefixShort, subscriptionPrefixLong)
} else {
this
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,6 @@ class SqlNormalizedCacheTest {

companion object {
val STANDARD_KEY = CacheKey("key")
val QUERY_ROOT_KEY = CacheKey.rootKey()
val QUERY_ROOT_KEY = CacheKey.QUERY_ROOT
}
}
4 changes: 2 additions & 2 deletions tests/cache-control/src/commonTest/kotlin/DoNotStoreTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,11 @@ class DoNotStoreTest {
)

apolloClient.apolloStore.accessCache { cache ->
val authRecord = cache.loadRecord(CacheKey("auth"), CacheHeaders.NONE)!!
val authRecord = cache.loadRecord(CacheKey.MUTATION_ROOT.append("auth"), CacheHeaders.NONE)!!
// No password in field key
assertContentEquals(listOf("signIn"), authRecord.fields.keys)

val signInRecord = cache.loadRecord(CacheKey("auth").append("signIn"), CacheHeaders.NONE)!!
val signInRecord = cache.loadRecord(CacheKey.MUTATION_ROOT.append("auth", "signIn"), CacheHeaders.NONE)!!
// No token in record
assertContentEquals(listOf("userData"), signInRecord.fields.keys)
}
Expand Down
1 change: 1 addition & 0 deletions tests/defer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ kotlin {
apollo {
service("base") {
packageName.set("defer")
generateFragmentImplementations.set(true)
}
}
Loading