Skip to content

Commit

Permalink
Add Kdoc to pagination related classes, and improve usage doc (#5888)
Browse files Browse the repository at this point in the history
* Add Kdoc to pagination related classes

* Improve cache pagination doc

* Apply suggestions from code review

KDoc tweaks

Co-authored-by: Martin Bonnin <martin@mbonnin.net>

* Use Note markup

---------

Co-authored-by: Martin Bonnin <martin@mbonnin.net>
  • Loading branch information
BoD and martinbonnin committed May 15, 2024
1 parent 1da0710 commit d457852
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 48 deletions.
64 changes: 39 additions & 25 deletions design-docs/Normalized cache pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ If your schema uses a different pagination style, you can still use the paginati

#### Pagination arguments

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

Going back to the example above with `usersPage`:

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

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

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

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.
The field name no longer includes the `page` argument, which means watching `UsersPage(page = 1)` or any page will observe the same list.

Here's what happens when fetching the second page:

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

We can see that the field containing the first page was overwritten by the second page.
The field containing the first page was overwritten by the second page.

This is because the cache key is now the same for all pages.
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.

#### Record merging

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

```kotlin
Expand All @@ -266,11 +269,11 @@ val apolloStore = ApolloStore(
normalizedCacheFactory = cacheFactory,
cacheKeyGenerator = TypePolicyCacheKeyGenerator,
apolloResolver = FieldPolicyApolloResolver,
recordMerger = FieldRecordMerger(MyFieldMerger)
recordMerger = FieldRecordMerger(MyFieldMerger), // Configure the store with the custom merger
)
```

With this, the cache will look like this after fetching the second page:
With this, the cache will be as expected after fetching the second page:

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

The `RecordMerger` shown above is simplistic: it can only append new pages to the end of the existing list.

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.
The `RecordMerger` shown above is simplistic: it will always append new items to the end of the existing list.
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.

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.
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.
For instance if the existing list is for page 1 and the incoming one is for page 2, we should append.

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.
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.

#### Metadata

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

However, this cannot work yet.
Normalization will make the `edges` field value be a list of **references** to the `UserEdge` records, and not the actual edges - so the
cast in `val edges = obj["edges"] as List<Map<String, Any?>>` will fail.
However, this cannot work yet.

Normalization will make the `usersConnection` field value be a **reference** to the `UserConnection` record, and not the actual connection.
Because of this, we won't be able to access its metadata inside the `RecordMerger` implementation.
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.

#### Embedded fields

To remediate this, we can use `embeddedFields` to configure the cache to skip normalization and store the actual list of edges instead of references:
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.

This is done with the `embeddedFields` argument of the `@typePolicy` directive:

```graphql
# Embed the value of the `usersConnection` field in the record
extend type Query @typePolicy(embeddedFields: "usersConnection")

# Embed the values of the `edges` field in the record
extend type UserConnection @typePolicy(embeddedFields: "edges")
```

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

```kotlin
object ConnectionFieldMerger : FieldRecordMerger.FieldMerger {
@Suppress("UNCHECKED_CAST")
override fun mergeFields(existing: FieldRecordMerger.FieldInfo, incoming: FieldRecordMerger.FieldInfo): FieldRecordMerger.FieldInfo {
// Get existing record metadata
// Get existing field metadata
val existingStartCursor = existing.metadata["startCursor"]
val existingEndCursor = existing.metadata["endCursor"]

// Get incoming record metadata
// Get incoming field metadata
val incomingBeforeArgument = incoming.metadata["before"]
val incomingAfterArgument = incoming.metadata["after"]

// Get values
val existingList = (existing.value as Map<String, Any?>)["edges"] as List<*>
val incomingList = (incoming.value as Map<String, Any?>)["edges"] as List<*>
// Get the lists
val existingList = (existing.value as Map<String, ApolloJsonElement>)["edges"] as List<*>
val incomingList = (incoming.value as Map<String, ApolloJsonElement>)["edges"] as List<*>

// Merge the lists
val mergedList: List<*> = if (incomingAfterArgument == existingEndCursor) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,32 @@ import com.apollographql.apollo3.api.CompiledNamedType
import com.apollographql.apollo3.api.InterfaceType
import com.apollographql.apollo3.api.ObjectType

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

/**
* A context passed to [EmbeddedFieldsProvider.getEmbeddedFields].
* @see [EmbeddedFieldsProvider.getEmbeddedFields]
*/
@ApolloExperimental
class EmbeddedFieldsContext(
val parentType: CompiledNamedType,
)

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

/**
* A [Relay connection types](https://relay.dev/graphql/connections.htm#sec-Connection-Types) aware [EmbeddedFieldsProvider].
*/
@ApolloExperimental
class ConnectionEmbeddedFieldsProvider(
/**
* Fields that are a Connection, associated with their parent type.
*/
connectionFields: Map<String, List<String>>,

/**
* The connection type names.
*/
connectionTypes: Set<String>,
) : EmbeddedFieldsProvider {
companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,44 @@ import com.apollographql.apollo3.annotations.ApolloExperimental
import com.apollographql.apollo3.api.CompiledField
import com.apollographql.apollo3.api.Executable

/**
* A generator for field names.
*
* For instance, [FieldNameGenerator] can be used to exclude certain pagination arguments when storing a connection field.
*/
@ApolloExperimental
interface FieldNameGenerator {
/**
* Returns the field name to use within its parent [Record].
*/
fun getFieldName(context: FieldNameContext): String
}

/**
* Context passed to the [FieldNameGenerator.getFieldName] method.
*/
@ApolloExperimental
class FieldNameContext(
val parentType: String,
val field: CompiledField,
val variables: Executable.Variables,
)

/**
* A [FieldNameGenerator] that returns the field name with its arguments, excluding pagination arguments defined with the
* `@fieldPolicy(forField: "...", paginationArgs: "...")` directive.
*/
@ApolloExperimental
object DefaultFieldNameGenerator : FieldNameGenerator {
override fun getFieldName(context: FieldNameContext): String {
return context.field.nameWithArguments(context.variables)
}
}

/**
* A [FieldNameGenerator] that generates field names excluding
* [Relay connection types](https://relay.dev/graphql/connections.htm#sec-Connection-Types) pagination arguments.
*/
@ApolloExperimental
class ConnectionFieldNameGenerator(private val connectionFields: Map<String, List<String>>) : FieldNameGenerator {
companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,72 @@ package com.apollographql.apollo3.cache.normalized.api
import com.apollographql.apollo3.annotations.ApolloExperimental
import com.apollographql.apollo3.api.CompiledField
import com.apollographql.apollo3.api.Executable
import com.apollographql.apollo3.api.json.ApolloJsonElement

/**
* A generator for arbitrary metadata associated with objects.
* For example, information about pagination can later be used to merge pages (see [RecordMerger]).
*
* The metadata is stored and attached to the object's field in the [Record] resulting from the normalization.
* For instance, given the query `query MyQuery { foo }` and an implementation of [metadataForObject] returning
* `mapOf("key", 0)`, the resulting Record will look like `fields: { foo: bar }, metadata: { foo: { key: 0 } }`.
*
* @see [Record.metadata]
*/
@ApolloExperimental
interface MetadataGenerator {
fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?>
/**
* Returns metadata for the given object.
* This is called for every field in the response, during normalization.
*
* The type of the object can be found in `context.field.type`.
*
* @param obj the object to generate metadata for.
* @param context contains the object's field and the variables of the operation execution.
*/
fun metadataForObject(obj: ApolloJsonElement, context: MetadataGeneratorContext): Map<String, ApolloJsonElement>
}

/**
* Additional context passed to the [MetadataGenerator.metadataForObject] method.
*/
@ApolloExperimental
class MetadataGeneratorContext(
val field: CompiledField,
val variables: Executable.Variables,
) {
fun argumentValue(argumentName: String): Any? {
fun argumentValue(argumentName: String): ApolloJsonElement {
return field.argumentValue(argumentName, variables).getOrNull()
}

fun allArgumentValues(): Map<String, Any?> {
fun allArgumentValues(): Map<String, ApolloJsonElement> {
return field.argumentValues(variables) { !it.definition.isPagination }
}
}

/**
* Default [MetadataGenerator] that returns empty metadata.
*/
@ApolloExperimental
object EmptyMetadataGenerator : MetadataGenerator {
override fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?> = emptyMap()
override fun metadataForObject(obj: ApolloJsonElement, context: MetadataGeneratorContext): Map<String, ApolloJsonElement> = emptyMap()
}

/**
* A [MetadataGenerator] that generates metadata for
* [Relay connection types](https://relay.dev/graphql/connections.htm#sec-Connection-Types).
* Collaborates with [ConnectionRecordMerger] to merge pages of a connection.
*
* Either `pageInfo.startCursor` and `pageInfo.endCursor`, or `edges.cursor` must be present in the selection.
*/
@ApolloExperimental
class ConnectionMetadataGenerator(private val connectionTypes: Set<String>) : MetadataGenerator {
@Suppress("UNCHECKED_CAST")
override fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?> {
override fun metadataForObject(obj: ApolloJsonElement, context: MetadataGeneratorContext): Map<String, ApolloJsonElement> {
if (context.field.type.rawType().name in connectionTypes) {
obj as Map<String, Any?>
val pageInfo = obj["pageInfo"] as? Map<String, Any?>
val edges = obj["edges"] as? List<Map<String, Any?>>
obj as Map<String, ApolloJsonElement>
val pageInfo = obj["pageInfo"] as? Map<String, ApolloJsonElement>
val edges = obj["edges"] as? List<Map<String, ApolloJsonElement>>
if (edges == null && pageInfo == null) {
return emptyMap()
}
Expand Down

0 comments on commit d457852

Please sign in to comment.