wasyl
11/22/2023, 7:04 PM@fieldPolicy
as in the documentation:
type Query {
book(id: String!): Book
}
# ...
extend type Query @fieldPolicy(forField: "book", keyArgs: "id")
extend type Book @typePolicy(keyFields: "id")
Note that the `book`'s result is optional. It's possible to get into a state where two observers for the same query have two different results, if:
• the backend returns a response with book ("book": { ... }
)
• a second observer fetches the same thing from the network but this time the server doesn't return a book
• the first original observer will not be updated with null
book. The second will emit data with null
This is both surprising and not — on one hand, it seems reasonable that fields with @fieldPolicy
should always return an item if it's found in the cache. On the other hand, feels like two observers observing the same query should always have the values in sync. Seems to me like reading from cache goes through a different code path than deserializing the json — one returns null
just like in the response, the other returns an object since @fieldPolicy
points to an object that exists in the cache.
Is this just incorrect usage of @fieldPolicy
? That is, should it only be used if the field can't change from non-null to null?mbonnin
11/22/2023, 7:50 PM@fieldPolicy
wasyl
11/22/2023, 8:02 PMid
argument 😬 which is not great now that I think of it)
But yeah I'd expect the record here to take precedence, since afaiu it's closer to whatever the server has returned last. I'm still not clear if using @fieldPolicy
in this case is allowed in the first place, perhaps there's a hard requirement that the value doesn't disappear. It is kind of tricky though in case of data that does in fact disappear over time, given that there's no (?) pruning of unreachable objectswasyl
11/22/2023, 8:06 PMI wonder if it's just a matter of changing the order here: if we have a value in the record already take it, if not, go throughThat'd work only if@fieldPolicy
DefaultCacheResolver.resolveField(field, variables, parent, parentId)
distinguishes between null
and absent
, right? I mean, in the specific scenario I linked, there exists an entry in the cache for book(someId): null
but the value is null
. Not sure that'd qualify as a cache hitmbonnin
11/22/2023, 8:07 PMmbonnin
11/22/2023, 8:08 PMnull
in the JSON is a value (unless there was an associated errors but that's opening another door)mbonnin
11/22/2023, 8:09 PMwasyl
11/22/2023, 8:09 PMunless there was an associated errors but that's opening another doorfunny you should say that, the original-original case that I started with involved the field being null because there were associated errors 😄 But then I realized we may not use field policy correctly so I focused on that
mbonnin
11/22/2023, 8:10 PMwasyl
11/22/2023, 8:13 PMmbonnin
11/22/2023, 8:13 PMnull
in places where we shouldn'twasyl
11/22/2023, 8:14 PMmbonnin
11/22/2023, 8:16 PMFieldPolicyCacheResolver
with the logic inverted (only lookup by id arg if the record doesn't already contain something)? If working well, we could make it the default in the libmbonnin
11/22/2023, 8:17 PMwasyl
11/22/2023, 8:25 PMnull
or replace the current one completely? 🤔 The current one is
internal object IdBaseCacheKeyGenerator : CacheKeyGenerator, CacheKeyResolver() {
private const val FIELD_ID = "id"
private const val FIELD_IDS = "ids"
override fun cacheKeyForObject(
obj: Map<String, Any?>,
context: CacheKeyGeneratorContext,
) = obj[FIELD_ID]?.asCacheKey() ?: TypePolicyCacheKeyGenerator.cacheKeyForObject(obj, context)
override fun cacheKeyForField(
field: CompiledField,
variables: Executable.Variables,
): CacheKey? = field.resolveArgument(FIELD_ID, variables)?.asCacheKey()
override fun listOfCacheKeysForField(
field: CompiledField,
variables: Executable.Variables,
): List<CacheKey?>? {
val ids = field.resolveArgument(FIELD_IDS, variables)
return if (ids is List<*>) {
ids.map { it?.asCacheKey() }
} else {
null
}
}
private fun Any.asCacheKey() = (this as? String)
?.takeUnless(String::isBlank)
?.let(::CacheKey)
}
mbonnin
11/22/2023, 8:26 PMCacheKeyGenerator
, you need to change the CacheResolver
(I know this is confusing but... didn't really find better names, this stuff is confusing)mbonnin
11/22/2023, 8:27 PMmbonnin
11/22/2023, 8:28 PMCacheResolver
and implement resolveField
completely (including the list handling provided by CacheKeyGenerator
if you're using it)wasyl
11/23/2023, 5:03 PMFieldPolicyCacheResolver
because I (sorry!) used @fieldPolicy
annotation in the snippet but in the code I was using a custom CacheKeyResolver
. So I copied CacheResolver#resolveField
instead, and I made it so that the abstract fun cacheKeyForField
is called only after the default resolver doesn't find anything. I did it only for non-list types too just for PoC purposes (img with diff for easier comparison)
override fun resolveField(
field: CompiledField,
variables: Executable.Variables,
parent: Map<String, @JvmSuppressWildcards Any?>,
parentId: String,
): Any? {
var type = field.type
if (type is CompiledNotNullType) {
type = type.ofType
}
if (type is CompiledNamedType && type.isComposite()) {
val result = try {
return DefaultCacheResolver.resolveField(field, variables, parent, parentId)
} catch (ex: CacheMissException) {
cacheKeyForField(field, variables)
}
if (result != null) {
return result
}
}
if (type is CompiledListType) {
type = type.ofType
if (type is CompiledNotNullType) {
type = type.ofType
}
if (type is CompiledNamedType && type.isComposite()) {
val result = listOfCacheKeysForField(field, variables)
if (result != null) {
return result
}
}
}
return DefaultCacheResolver.resolveField(field, variables, parent, parentId)
}
With this I observe expected result:
• type policy is respected, I don't observe network request when making a query with type policy for object that is in the cache already
• if the backend returns an object and then later null
, I get the null
in subsequent observers
• I still get the object when making another query with fieldPolicy
, because it's in the cachewasyl
11/23/2023, 5:06 PMFieldPolicyCacheResolver
and CacheKeyResolver
, but I see a number of other resolvers in the code too, and I'm not sure what they're for 😄
Good news is that the API is perfect in that we can step down and implement everything manually, adjust the behavior only in our project and we we don't need any changes in Apollo 👌 So question is whether it's something that should be upstreamed or notwasyl
11/23/2023, 5:12 PMWe happen to know that this query returns whichever Book object has an id field that matches the required argument.
, which I think is vital — not every query satisifes this
— just copying and pasting the sample implementation seems to work for a simple case, but afaiu it breaks down if the query doesn't return an object with the id
argument — say Query.nextBookRecommendationFor(id: $bookId) { book { ... } }
. Perhaps it would make sense to make it more obvious, like have the sample implement an opt-in for a particular mbonnin
11/23/2023, 5:16 PMFieldPolicyCacheResolver
. FieldPolicyCacheResolver
is the only place that uses keyArgs
and I'm borderline tempted to deprecated CacheKeyResolver
altogethermbonnin
11/23/2023, 5:18 PMwasyl
11/23/2023, 5:30 PMwasyl
11/23/2023, 5:33 PMCacheKeyResolver
— so the only way to implement field policy would be with an extension or by manually implementing full CacheResolver
, if someone understands the details and is comfortable with the APIs? That'd be a +1 from me really, because AFAIU it would make it much more difficult to implement a catch-all field policy which is rarely always correct. But I'm also somewhat sure a) someone would complain because they do in fact need a catch-all resolver, and b) extensions are more verbosembonnin
11/23/2023, 5:35 PMCacheKeyResolver
feels a bit asymmetrical. It's sole reason for existence is to handle lists of id arguments but then why does it stop at dimension 1. Agree there aren't many 2-dimension inputs around but still feels weird. Also requires the caller to know about keyArgs
wasyl
11/23/2023, 5:40 PMmbonnin
11/23/2023, 5:41 PMmbonnin
11/23/2023, 5:42 PMextend type Query @fieldPolicy(forField: "books", keyArg: "id", dimension: 1)
But that's quite a mouthfulwasyl
11/23/2023, 5:51 PMkeyArgsList
CacheKeyResolver
is limiting too, and again for a rare scenario with nested lists you could implement your own CacheResolver
right?mbonnin
11/23/2023, 6:01 PM