Skip to content

Commit d457852

Browse files
BoDmartinbonnin
andauthored
Add Kdoc to pagination related classes, and improve usage doc (#5888)
* Add Kdoc to pagination related classes * Improve cache pagination doc * Apply suggestions from code review KDoc tweaks Co-authored-by: Martin Bonnin <[email protected]> * Use Note markup --------- Co-authored-by: Martin Bonnin <[email protected]>
1 parent 1da0710 commit d457852

File tree

8 files changed

+175
-48
lines changed

8 files changed

+175
-48
lines changed

design-docs/Normalized cache pagination.md

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ If your schema uses a different pagination style, you can still use the paginati
210210

211211
#### Pagination arguments
212212

213-
The `@fieldPolicy` directive has a `paginationArgs` argument that can be used to specify the arguments that should be omitted from the cache key.
213+
The `@fieldPolicy` directive has a `paginationArgs` argument that can be used to specify the arguments that should be omitted from the field name.
214214

215215
Going back to the example above with `usersPage`:
216216

@@ -220,6 +220,9 @@ extend type Query
220220
@fieldPolicy(forField: "usersPage" paginationArgs: "page")
221221
```
222222

223+
> [!NOTE]
224+
> This can also be done programmatically by configuring the `ApolloStore` with a `FieldNameGenerator` implementation.
225+
223226
With that in place, after fetching the first page, the cache will look like this:
224227

225228
| Cache Key | Record |
@@ -228,7 +231,7 @@ With that in place, after fetching the first page, the cache will look like this
228231
| user:1 | id: 1, name: John Smith |
229232
| user:2 | id: 2, name: Jane Doe |
230233

231-
Notice how the cache key no longer includes the `page` argument, which means watching `UsersPage(page = 1)` (or any page) is enough to observe the whole list.
234+
The field name no longer includes the `page` argument, which means watching `UsersPage(page = 1)` or any page will observe the same list.
232235

233236
Here's what happens when fetching the second page:
234237

@@ -240,13 +243,13 @@ Here's what happens when fetching the second page:
240243
| user:3 | id: 3, name: Peter Parker |
241244
| user:4 | id: 4, name: Bruce Wayne |
242245

243-
We can see that the field containing the first page was overwritten by the second page.
246+
The field containing the first page was overwritten by the second page.
244247

245-
This is because the cache key is now the same for all pages.
248+
This is because the field name is now the same for all pages and the default merging strategy is to overwrite existing fields with the new value.
246249

247250
#### Record merging
248251

249-
To fix this we need to supply the store with a piece of code that can merge new pages with the existing list.
252+
To fix this we need to supply the store with a piece of code that can merge the lists in a sensible way.
250253
This is done by passing a `RecordMerger` to the `ApolloStore` constructor:
251254

252255
```kotlin
@@ -266,11 +269,11 @@ val apolloStore = ApolloStore(
266269
normalizedCacheFactory = cacheFactory,
267270
cacheKeyGenerator = TypePolicyCacheKeyGenerator,
268271
apolloResolver = FieldPolicyApolloResolver,
269-
recordMerger = FieldRecordMerger(MyFieldMerger)
272+
recordMerger = FieldRecordMerger(MyFieldMerger), // Configure the store with the custom merger
270273
)
271274
```
272275

273-
With this, the cache will look like this after fetching the second page:
276+
With this, the cache will be as expected after fetching the second page:
274277

275278
| Cache Key | Record |
276279
|------------|-------------------------------------------------------------------------------|
@@ -280,13 +283,13 @@ With this, the cache will look like this after fetching the second page:
280283
| user:3 | id: 3, name: Peter Parker |
281284
| user:4 | id: 4, name: Bruce Wayne |
282285

283-
The `RecordMerger` shown above is simplistic: it can only append new pages to the end of the existing list.
284-
285-
A more sophisticated implementation could look at the contents of the incoming page and decide where to append / insert the items into the existing list.
286+
The `RecordMerger` shown above is simplistic: it will always append new items to the end of the existing list.
287+
In a real app, we need to look at the contents of the incoming page and decide if and where to append / insert the items.
286288

287-
To do that it is often necessary to have access to the arguments used to fetch a record inside the `RecordMerger`, to decide where to insert the new items.
289+
To do that it is usually necessary to have access to the arguments that were used to fetch the existing/incoming lists (e.g. the page number), to decide what to do with the new items.
290+
For instance if the existing list is for page 1 and the incoming one is for page 2, we should append.
288291

289-
Fields in records can actually have arbitrary metadata associated with them, in addition to their values. We'll use this to implement more sophisticated merging.
292+
Fields in records can have arbitrary metadata attached to them, in addition to their value. We'll use this to implement a more capable merging strategy.
290293

291294
#### Metadata
292295

@@ -307,10 +310,10 @@ This is done by passing a `MetadataGenerator` to the `ApolloStore` constructor:
307310
```kotlin
308311
class ConnectionMetadataGenerator : MetadataGenerator {
309312
@Suppress("UNCHECKED_CAST")
310-
override fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?> {
313+
override fun metadataForObject(obj: ApolloJsonElement, context: MetadataGeneratorContext): Map<String, ApolloJsonElement> {
311314
if (context.field.type.rawType().name == "UserConnection") {
312-
obj as Map<String, Any?>
313-
val edges = obj["edges"] as List<Map<String, Any?>>
315+
obj as Map<String, ApolloJsonElement>
316+
val edges = obj["edges"] as List<Map<String, ApolloJsonElement>>
314317
val startCursor = edges.firstOrNull()?.get("cursor") as String?
315318
val endCursor = edges.lastOrNull()?.get("cursor") as String?
316319
return mapOf(
@@ -325,35 +328,46 @@ class ConnectionMetadataGenerator : MetadataGenerator {
325328
}
326329
```
327330

328-
However, this cannot work yet.
329-
Normalization will make the `edges` field value be a list of **references** to the `UserEdge` records, and not the actual edges - so the
330-
cast in `val edges = obj["edges"] as List<Map<String, Any?>>` will fail.
331+
However, this cannot work yet.
332+
333+
Normalization will make the `usersConnection` field value be a **reference** to the `UserConnection` record, and not the actual connection.
334+
Because of this, we won't be able to access its metadata inside the `RecordMerger` implementation.
335+
Furthermore, the `edges` field value will be a list of **references** to the `UserEdge` records which will contain the item's list index in their cache key (e.g. `usersConnection.edges.0`, `usersConnection.edges.1`) which will break the merging logic.
331336

332337
#### Embedded fields
333338

334-
To remediate this, we can use `embeddedFields` to configure the cache to skip normalization and store the actual list of edges instead of references:
339+
To remediate this, we can configure the cache to skip normalization for certain fields. When doing so, the value will be embedded directly into the record instead of being referenced.
340+
341+
This is done with the `embeddedFields` argument of the `@typePolicy` directive:
335342

336343
```graphql
344+
# Embed the value of the `usersConnection` field in the record
345+
extend type Query @typePolicy(embeddedFields: "usersConnection")
346+
347+
# Embed the values of the `edges` field in the record
337348
extend type UserConnection @typePolicy(embeddedFields: "edges")
338349
```
339350

340-
Now that we have the metadata in place, we can access it inside the `RecordMerger` (simplified for brevity):
351+
> [!NOTE]
352+
> This can also be done programmatically by configuring the `ApolloStore` with an `EmbeddedFieldsProvider` implementation.
353+
354+
Now that we have the metadata and embedded fields in place, we can implement the `RecordMerger` (simplified for brevity):
341355

342356
```kotlin
343357
object ConnectionFieldMerger : FieldRecordMerger.FieldMerger {
344358
@Suppress("UNCHECKED_CAST")
345359
override fun mergeFields(existing: FieldRecordMerger.FieldInfo, incoming: FieldRecordMerger.FieldInfo): FieldRecordMerger.FieldInfo {
346-
// Get existing record metadata
360+
// Get existing field metadata
347361
val existingStartCursor = existing.metadata["startCursor"]
348362
val existingEndCursor = existing.metadata["endCursor"]
349363

350-
// Get incoming record metadata
364+
// Get incoming field metadata
351365
val incomingBeforeArgument = incoming.metadata["before"]
352366
val incomingAfterArgument = incoming.metadata["after"]
353367

354-
// Get values
355-
val existingList = (existing.value as Map<String, Any?>)["edges"] as List<*>
356-
val incomingList = (incoming.value as Map<String, Any?>)["edges"] as List<*>
368+
// Get the lists
369+
val existingList = (existing.value as Map<String, ApolloJsonElement>)["edges"] as List<*>
370+
val incomingList = (incoming.value as Map<String, ApolloJsonElement>)["edges"] as List<*>
357371

358372
// Merge the lists
359373
val mergedList: List<*> = if (incomingAfterArgument == existingEndCursor) {

libraries/apollo-normalized-cache-api-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/EmbeddedFieldsProvider.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,32 @@ import com.apollographql.apollo3.api.CompiledNamedType
55
import com.apollographql.apollo3.api.InterfaceType
66
import com.apollographql.apollo3.api.ObjectType
77

8+
/**
9+
* A provider for fields whose value should be embedded in their [Record], rather than being dereferenced during normalization.
10+
*
11+
* An [EmbeddedFieldsProvider] can be used in conjunction with [RecordMerger] and [MetadataGenerator] to access multiple fields and their metadata in a single
12+
* [Record].
13+
*/
814
@ApolloExperimental
915
interface EmbeddedFieldsProvider {
16+
/**
17+
* Returns the fields that should be embedded, given a [context]`.parentType`.
18+
*/
1019
fun getEmbeddedFields(context: EmbeddedFieldsContext): List<String>
1120
}
1221

22+
/**
23+
* A context passed to [EmbeddedFieldsProvider.getEmbeddedFields].
24+
* @see [EmbeddedFieldsProvider.getEmbeddedFields]
25+
*/
1326
@ApolloExperimental
1427
class EmbeddedFieldsContext(
1528
val parentType: CompiledNamedType,
1629
)
1730

31+
/**
32+
* An [EmbeddedFieldsProvider] that returns the fields specified by the `@typePolicy(embeddedFields: "...")` directive.
33+
*/
1834
@ApolloExperimental
1935
object DefaultEmbeddedFieldsProvider : EmbeddedFieldsProvider {
2036
override fun getEmbeddedFields(context: EmbeddedFieldsContext): List<String> {
@@ -29,9 +45,19 @@ private val CompiledNamedType.embeddedFields: List<String>
2945
else -> emptyList()
3046
}
3147

48+
/**
49+
* A [Relay connection types](https://relay.dev/graphql/connections.htm#sec-Connection-Types) aware [EmbeddedFieldsProvider].
50+
*/
3251
@ApolloExperimental
3352
class ConnectionEmbeddedFieldsProvider(
53+
/**
54+
* Fields that are a Connection, associated with their parent type.
55+
*/
3456
connectionFields: Map<String, List<String>>,
57+
58+
/**
59+
* The connection type names.
60+
*/
3561
connectionTypes: Set<String>,
3662
) : EmbeddedFieldsProvider {
3763
companion object {

libraries/apollo-normalized-cache-api-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/FieldNameGenerator.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,44 @@ import com.apollographql.apollo3.annotations.ApolloExperimental
44
import com.apollographql.apollo3.api.CompiledField
55
import com.apollographql.apollo3.api.Executable
66

7+
/**
8+
* A generator for field names.
9+
*
10+
* For instance, [FieldNameGenerator] can be used to exclude certain pagination arguments when storing a connection field.
11+
*/
712
@ApolloExperimental
813
interface FieldNameGenerator {
14+
/**
15+
* Returns the field name to use within its parent [Record].
16+
*/
917
fun getFieldName(context: FieldNameContext): String
1018
}
1119

20+
/**
21+
* Context passed to the [FieldNameGenerator.getFieldName] method.
22+
*/
1223
@ApolloExperimental
1324
class FieldNameContext(
1425
val parentType: String,
1526
val field: CompiledField,
1627
val variables: Executable.Variables,
1728
)
1829

30+
/**
31+
* A [FieldNameGenerator] that returns the field name with its arguments, excluding pagination arguments defined with the
32+
* `@fieldPolicy(forField: "...", paginationArgs: "...")` directive.
33+
*/
1934
@ApolloExperimental
2035
object DefaultFieldNameGenerator : FieldNameGenerator {
2136
override fun getFieldName(context: FieldNameContext): String {
2237
return context.field.nameWithArguments(context.variables)
2338
}
2439
}
2540

41+
/**
42+
* A [FieldNameGenerator] that generates field names excluding
43+
* [Relay connection types](https://relay.dev/graphql/connections.htm#sec-Connection-Types) pagination arguments.
44+
*/
2645
@ApolloExperimental
2746
class ConnectionFieldNameGenerator(private val connectionFields: Map<String, List<String>>) : FieldNameGenerator {
2847
companion object {

libraries/apollo-normalized-cache-api-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/MetadataGenerator.kt

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,72 @@ package com.apollographql.apollo3.cache.normalized.api
33
import com.apollographql.apollo3.annotations.ApolloExperimental
44
import com.apollographql.apollo3.api.CompiledField
55
import com.apollographql.apollo3.api.Executable
6+
import com.apollographql.apollo3.api.json.ApolloJsonElement
67

8+
/**
9+
* A generator for arbitrary metadata associated with objects.
10+
* For example, information about pagination can later be used to merge pages (see [RecordMerger]).
11+
*
12+
* The metadata is stored and attached to the object's field in the [Record] resulting from the normalization.
13+
* For instance, given the query `query MyQuery { foo }` and an implementation of [metadataForObject] returning
14+
* `mapOf("key", 0)`, the resulting Record will look like `fields: { foo: bar }, metadata: { foo: { key: 0 } }`.
15+
*
16+
* @see [Record.metadata]
17+
*/
718
@ApolloExperimental
819
interface MetadataGenerator {
9-
fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?>
20+
/**
21+
* Returns metadata for the given object.
22+
* This is called for every field in the response, during normalization.
23+
*
24+
* The type of the object can be found in `context.field.type`.
25+
*
26+
* @param obj the object to generate metadata for.
27+
* @param context contains the object's field and the variables of the operation execution.
28+
*/
29+
fun metadataForObject(obj: ApolloJsonElement, context: MetadataGeneratorContext): Map<String, ApolloJsonElement>
1030
}
1131

32+
/**
33+
* Additional context passed to the [MetadataGenerator.metadataForObject] method.
34+
*/
1235
@ApolloExperimental
1336
class MetadataGeneratorContext(
1437
val field: CompiledField,
1538
val variables: Executable.Variables,
1639
) {
17-
fun argumentValue(argumentName: String): Any? {
40+
fun argumentValue(argumentName: String): ApolloJsonElement {
1841
return field.argumentValue(argumentName, variables).getOrNull()
1942
}
2043

21-
fun allArgumentValues(): Map<String, Any?> {
44+
fun allArgumentValues(): Map<String, ApolloJsonElement> {
2245
return field.argumentValues(variables) { !it.definition.isPagination }
2346
}
2447
}
2548

49+
/**
50+
* Default [MetadataGenerator] that returns empty metadata.
51+
*/
2652
@ApolloExperimental
2753
object EmptyMetadataGenerator : MetadataGenerator {
28-
override fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?> = emptyMap()
54+
override fun metadataForObject(obj: ApolloJsonElement, context: MetadataGeneratorContext): Map<String, ApolloJsonElement> = emptyMap()
2955
}
3056

57+
/**
58+
* A [MetadataGenerator] that generates metadata for
59+
* [Relay connection types](https://relay.dev/graphql/connections.htm#sec-Connection-Types).
60+
* Collaborates with [ConnectionRecordMerger] to merge pages of a connection.
61+
*
62+
* Either `pageInfo.startCursor` and `pageInfo.endCursor`, or `edges.cursor` must be present in the selection.
63+
*/
3164
@ApolloExperimental
3265
class ConnectionMetadataGenerator(private val connectionTypes: Set<String>) : MetadataGenerator {
3366
@Suppress("UNCHECKED_CAST")
34-
override fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?> {
67+
override fun metadataForObject(obj: ApolloJsonElement, context: MetadataGeneratorContext): Map<String, ApolloJsonElement> {
3568
if (context.field.type.rawType().name in connectionTypes) {
36-
obj as Map<String, Any?>
37-
val pageInfo = obj["pageInfo"] as? Map<String, Any?>
38-
val edges = obj["edges"] as? List<Map<String, Any?>>
69+
obj as Map<String, ApolloJsonElement>
70+
val pageInfo = obj["pageInfo"] as? Map<String, ApolloJsonElement>
71+
val edges = obj["edges"] as? List<Map<String, ApolloJsonElement>>
3972
if (edges == null && pageInfo == null) {
4073
return emptyMap()
4174
}

0 commit comments

Comments
 (0)