From b000ca31969db88f53f0947cab8fe8c77c45ab33 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 12 Dec 2019 12:45:56 -0500 Subject: [PATCH 01/10] Documentation for custom read functions in field policies. --- docs/source/caching/cache-configuration.md | 298 ++++++++++++++++++++- 1 file changed, 289 insertions(+), 9 deletions(-) diff --git a/docs/source/caching/cache-configuration.md b/docs/source/caching/cache-configuration.md index eb5e1b20e71..dde6a1c88dd 100644 --- a/docs/source/caching/cache-configuration.md +++ b/docs/source/caching/cache-configuration.md @@ -267,7 +267,7 @@ Here are the `FieldPolicy` type and its related types: ```ts export type FieldPolicy = { - keyArgs?: KeySpecifier | KeyArgsFunction; + keyArgs?: KeySpecifier | KeyArgsFunction | false; read?: FieldReadFunction; merge?: FieldMergeFunction; }; @@ -277,12 +277,31 @@ type KeyArgsFunction = ( context: { typename: string; variables: Record; + policies: Policies; }, ) => string | null | void; +interface FieldFunctionOptions { + args: Record | null; + field: string | FieldNode; + variables?: Record; + policies: Policies; + isReference(obj: any): obj is Reference; + toReference(obj: StoreObject): Reference; +} + +interface ReadFunctionOptions extends FieldFunctionOptions { + readField( + nameOrField: string | FieldNode, + foreignObjOrRef?: StoreObject | Reference, + ): Readonly; + storage: Record; + invalidate(): void; +} + type FieldReadFunction = ( existing: Readonly | undefined, - options: FieldFunctionOptions, + options: ReadFunctionOptions, ) => TResult; type FieldMergeFunction = ( @@ -290,13 +309,6 @@ type FieldMergeFunction = ( incoming: Readonly, options: FieldFunctionOptions, ) => TExisting; - -interface FieldFunctionOptions { - args: Record; - parentObject: Readonly; - field: FieldNode; - variables?: Record; -} ``` In the sections below, we will break down these types with explanations and examples. @@ -338,3 +350,271 @@ const cache = new InMemoryCache({ That said, you might be able to assume the `token` is always the same, or you might not be worried about duplicating field values in the cache, so neglecting to specify `keyArgs: ["key"]` probably will not cause any major problems. Use `keyArgs` when it helps. On the other hand, perhaps you've requested the secret from the server using the access `token`, but you want various components on your page to be able to access the secret using only they `key`, without having to know the `token`. Storing the value in the cache using only the `key` makes this retrieval possible. + +### Reading and merging fields + +The GraphQL query language provides a uniform and ergonomic way of reading nested, tree-shaped data from a data graph. However, when you query the `InMemoryCache` rather than sending queries to a GraphQL server, you're reading data from a _client-side data graph_, which is almost always an incomplete copy of the data graph exposed by the server. + +To ensure your client-side data graph accurately reproduces the entities and relationships from your server-side data graph, while also extending the server graph with client-only information, you can define custom `read` and `merge` functions as part of any `FieldPolicy`. These functions will be invoked whenever the field is queried (`read`) or updated with new data (`merge`). + +#### Custom `read` functions + +The most basic custom `read` function simply returns existing data from the cache, without modification: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + name: { + read(name: string) { + return name; + }, + }, + + // Since read functions are so common, you can collapse the FieldPolicy + // into a single method, if you only need to define a read function. + age(age: number) { + return age; + }, + }, + }, + }, +}); +``` + +Neither of these `read` functions really needs to be defined, since they both do exactly what the cache already does by default: they return existing cache data. + +Things start to get interesting when you define a `read` function for data that does not exist in the cache: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + userId() { + return localStorage.loggedInUserId; + }, + }, + }, + }, +}); +``` + +Or when you want to tweak the existing data slightly: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + age(floatingPointAge: number) { + return Math.round(floatingPointAge); + }, + }, + }, + }, +}); +``` + +Or when you want to provide a default value for potentially nonexistent cache data: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + name(name = "Jane Doe") { + return name; + }, + + age(age = /* Median American age: */ 38.1) { + return age; + }, + }, + }, + }, +}); +``` + +Or when you want to define computed fields in terms of other fields: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + ageInDogYears(_, { readField }) { + // In TypeScript it can be useful to coerce the field value to a known + // type, such as number: + return readField("age") / 7; + }, + + // Since this field does not exist in the cache, we ignore the non-existent + // existing data given by the first parameter. + fullName(_, { readField }) { + const firstName = readField("firstName"); + const lastName = readField("lastName"); + if (firstName && lastName) { + return `${firstName} ${firstName}`; + } + // Returning undefined indicates the fullName field is missing, because + // one or more of its dependencies was not available. + }, + }, + }, + }, +}); +``` + +You can even read fields from other entities in the cache: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + youngestFriend(_, { readField }) { + let youngestFriend: object | undefined; + let minAge = Infinity; + const friends = readField("friends") || []; + friends.forEach(friend => { + // Passing an object or Reference as the second argument to readField + // causes the field value to be read from that entity instead of the + // current entity. + const friendAge = readField("age", friend); + if (friendAge < minAge) { + minAge = friendAge; + youngestFriend = friend; + } + }); + return youngestFriend; + }, + }, + }, + }, +}); +``` + +If the field takes arguments, a `read` function can be used to interpret those arguments: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + // Optionally allow asking for the person's age in any units. + age(ageInYears: number, { args }) { + if (args && typeof args.units === "string") { + return convertUnits(ageInYears, "years", args.units); + } + return ageInYears; + }, + + // Sorting/filtering/pagination of the person's list of friends. + friends(friendRefs: Reference[], { args, readField }) { + if (args && typeof args.sortBy === "string") { + // Sort the refs first, if requested. Note that friendRefs is an + // immutable array, which is why friendRefs.slice(0) is necessary. + friendRefs = friendRefs.slice(0).sort((a, b) => compareBy( + args.sortBy, + readField(args.sortBy, a), + readField(args.sortBy, b), + )); + } + + if (args && typeof args.limit === "number") { + // Offset/limit-based pagination: + if (typeof args.offset === "number") { + return friendRefs.slice(args.offset, args.offset + args.limit); + } + + // Other kinds of pagination: + if (typeof args.startId === "string") { + const offset = friendRefs.findIndex( + ref => readField("id", ref) === args.startId); + if (offset >= 0) { + return friendRefs.slice(offset, offset + args.limit); + } + } + + // Returning undefined indicates that the field is missing. + // Throwing an exception might also be appropriate here. + return; + } + + // Return the whole list if no pagination arguments provided. + return friendRefs; + }, + }, + }, + }, +}); +``` + +Now that you've gotten a taste for the power and flexibility of `read` functions, let's have a look at all the options that are provided by the second parameter: + +```ts +// These options are common to both read and merge functions: +interface FieldFunctionOptions { + // The final argument values passed to the field, after applying variables. + // If no arguments were provided, this property will be null. + args: Record | null; + + // The name of the field, equal to options.field.name.value when + // options.field is available. Useful if you reuse the same function for + // multiple fields, and you need to know which field you're currently + // processing. Always a string, even when options.field is null. + fieldName: string; + + // The FieldNode object used to read this field. Useful if you need to + // know about other attributes of the field, such as its directives. This + // option will be null when a string was passed to options.readField. + field: FieldNode | null; + + // The variables that were provided when reading the query that contained + // this field. Possibly undefined, if no variables were provided. + variables?: Record; + + // Utilities for handling { __ref: string } references. + isReference(obj: any): obj is Reference; + toReference(obj: StoreObject): Reference; + + // A reference to the Policies object created by passing typePolicies to + // the InMemoryCache constructor, for advanced/internal use. + policies: Policies; +} + +// These options are specific to read functions: +interface ReadFunctionOptions extends FieldFunctionOptions { + // Helper function for reading other fields within the current object. + // If a foreign object or reference is provided, the field will be read + // from that object instead of the current object, so this function can + // be used (together with isReference) to examine the cache outside the + // current object. If a FieldNode is passed instead of a string, and + // that FieldNode has arguments, the same options.variables will be used + // to compute the argument values. Note that this function will invoke + // custom read functions for other fields, if defined. Always returns + // immutable data (enforced with Object.freeze in development). + readField( + nameOrField: string | FieldNode, + foreignObjOrRef?: StoreObject | Reference, + ): Readonly; + + // A handy place to put field-specific data that you want to survive + // across multiple read function calls. Useful for caching. + storage: Record; + + // Call this function to invalidate any cached queries that previously + // consumed this field. If you use options.storage as a cache, setting a + // new value in the cache and then calling options.invalidate() can be a + // good way to deliver asynchronous results. + invalidate(): void; +} +``` + +You will almost never need to use all of these options at the same time, but each one has an important role to play when reading unusual fields from the cache. + +#### Custom `merge` functions + +TODO \ No newline at end of file From 5dc3fc9e3979fac308b364460be015c8fd5e3af7 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 13 Dec 2019 17:38:49 -0500 Subject: [PATCH 02/10] Beginnings of documentation for custom merge functions. --- docs/source/caching/cache-configuration.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/source/caching/cache-configuration.md b/docs/source/caching/cache-configuration.md index dde6a1c88dd..2deab2789c1 100644 --- a/docs/source/caching/cache-configuration.md +++ b/docs/source/caching/cache-configuration.md @@ -617,4 +617,18 @@ You will almost never need to use all of these options at the same time, but eac #### Custom `merge` functions -TODO \ No newline at end of file +If a `read` function allows customizing what happens when a field within an entity object is read from the cache, what about writing fields into the cache? + +The short answer is that a `FieldPolicy` object can contain a custom `merge` function that takes the field's existing value and its incoming value, and merges them together into a new value to be stored for that field within its parent entity. + +However, in order to understand when you might need to define a custom `merge` function, it's important to understand how the cache merges data by default, without any customization: + +1. When `cache.writeQuery({ query, data })` is called, the cache traverses the `query` and the `data` in parallel to find any objects within `data` that have a `__typename` and the necessary fields to compute a unique identifier for the object, using `keyFields` or `dataIdFromObject`. Not all objects have this information, but those that do will be stored in a flat `Map`-like data structure, with the ID strings as keys, and the objects as values. The root `data` object is almost always assigned a special `ROOT_QUERY` ID, since it contains root `Query` fields. + +2. If the normalized map already contains an entity object with the same ID, the fields of the new object will be automatically shallow-merged with the existing fields, _replacing_ any fields that overlap. This kind of merging happens automatically, and makes sense because we know the objects have the same ID, which means they represent the same logical entity. + +3. Although many of the replaced fields have scalar values, such as strings or numbers, some fields have object or array values. If an ID can be computed for these objects, we can assume they were already written into the cache with that ID (see step 1), and the original field value will be replaced with a special `{ __ref: }` object that refers to the normalized entity. If no ID can be computed, the object is treated as opaque data, and no attempt is made to merge it with other data. + +In short, the top-level fields of normalized entity objects are shallow-merged together, but no additional merging happens by default. Objects are never merged together unless they have the same ID, even if it's possible that they might represent the same logical entity, because mixing fields from different entities is a recipe for data graph inconsistencies. + +If this default behavior is insufficient for your needs, because you want to prevent existing field values from being completely replaced, or you want to translate the incoming data somehow before it is stored in the cache, that's when you should consider writing a custom `merge` function for the field. From 31fb4f483d24a1e9dd45faad2a2014944b1f4786 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 16 Dec 2019 16:20:31 -0500 Subject: [PATCH 03/10] Additional documentation for custom merge functions and pagination. --- docs/source/caching/cache-configuration.md | 174 ++++++++++++++++++++- 1 file changed, 168 insertions(+), 6 deletions(-) diff --git a/docs/source/caching/cache-configuration.md b/docs/source/caching/cache-configuration.md index 2deab2789c1..2c70b68e4ec 100644 --- a/docs/source/caching/cache-configuration.md +++ b/docs/source/caching/cache-configuration.md @@ -617,18 +617,180 @@ You will almost never need to use all of these options at the same time, but eac #### Custom `merge` functions -If a `read` function allows customizing what happens when a field within an entity object is read from the cache, what about writing fields into the cache? +If a `read` function can customize what happens when a field within an entity object is read from the cache, what about writing fields into the cache? -The short answer is that a `FieldPolicy` object can contain a custom `merge` function that takes the field's existing value and its incoming value, and merges them together into a new value to be stored for that field within its parent entity. +The short answer is that a `FieldPolicy` object can contain a custom `merge` function that takes the field's existing value and an incoming value, and merges them together into a new value that will be stored for that field. However, in order to understand when you might need to define a custom `merge` function, it's important to understand how the cache merges data by default, without any customization: -1. When `cache.writeQuery({ query, data })` is called, the cache traverses the `query` and the `data` in parallel to find any objects within `data` that have a `__typename` and the necessary fields to compute a unique identifier for the object, using `keyFields` or `dataIdFromObject`. Not all objects have this information, but those that do will be stored in a flat `Map`-like data structure, with the ID strings as keys, and the objects as values. The root `data` object is almost always assigned a special `ROOT_QUERY` ID, since it contains root `Query` fields. +1. When `cache.writeQuery({ query, data })` is called, the cache traverses `query` and `data` in parallel to find any objects within `data` that have a `__typename` and the necessary fields to compute a unique identifier for the object, using `keyFields` or `dataIdFromObject`. Not all objects have this information, but those that do will be stored in a flat `Map`-like data structure, with the ID strings as keys, and the objects as values. -2. If the normalized map already contains an entity object with the same ID, the fields of the new object will be automatically shallow-merged with the existing fields, _replacing_ any fields that overlap. This kind of merging happens automatically, and makes sense because we know the objects have the same ID, which means they represent the same logical entity. + > Note: the root `data` object is always assigned a special `ROOT_QUERY` ID, since it contains root `Query` fields. This is why you do not have to specify `keyFields` for the `Query` type. -3. Although many of the replaced fields have scalar values, such as strings or numbers, some fields have object or array values. If an ID can be computed for these objects, we can assume they were already written into the cache with that ID (see step 1), and the original field value will be replaced with a special `{ __ref: }` object that refers to the normalized entity. If no ID can be computed, the object is treated as opaque data, and no attempt is made to merge it with other data. +2. If the normalized map already contains an entity object with the same ID, the fields of the new object will be automatically shallow-merged into the existing fields, _replacing_ any fields that overlap. This kind of merging happens automatically, and makes sense in most cases because we know the objects have the same ID, which means they represent the same logical entity. -In short, the top-level fields of normalized entity objects are shallow-merged together, but no additional merging happens by default. Objects are never merged together unless they have the same ID, even if it's possible that they might represent the same logical entity, because mixing fields from different entities is a recipe for data graph inconsistencies. +3. By default, the cache does not attempt to merge the _values_ of top-level entity fields, even if those values are objects or arrays. If an ID could be computed for a nested object (or the object elements of an array), the object would already have been written into the cache with that ID (see step 1), and a special `{ __ref: }` object referring to the normalized entity would become the value of the field. When a field has a scalar value, or when no ID can be computed for an object value, the value is treated as opaque data, and no attempt is made to merge it with other data. + +To recap: the top-level fields of normalized entity objects are shallow-merged together, but no additional merging happens by default. Objects are never merged together unless they have the same ID, even if it's possible that they might represent the same logical entity, because mixing fields from different entities is a recipe for data graph inconsistencies. If this default behavior is insufficient for your needs, because you want to prevent existing field values from being completely replaced, or you want to translate the incoming data somehow before it is stored in the cache, that's when you should consider writing a custom `merge` function for the field. + +A simple but common use case for `merge` functions is to define what happens when an array-valued field is about to be overwritten by a new array. Often, it would be better to concatenate the arrays, rather than replacing the existing array: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Agenda: { + fields: { + tasks: { + merge(existing = [], incoming: any[]) { + return [...existing, ...incoming]; + }, + }, + }, + }, + }, +}); +``` + +Note that `existing` will be undefined the very first time the `merge` function is called, since the cache does not contain any data for this field (within this particular object) yet. The `existing = []` default parameter style is a convenient way to handle this case. + +You might be tempted to write this function in a more destructive, less "functional" style: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Agenda: { + fields: { + tasks: { + merge(existing = [], incoming: any[]) { + // Not allowed! + existing.push(...incoming); + return existing; + }, + }, + }, + }, + }, +}); +``` + +However, modifying existing data in the cache is forbidden, because altering the contents of cached objects without changing their references can prevent the cache from reporting changes to your application in some cases, and also interferes with the ability of the cache to produce immutable snapshots using the `cache.extract()` method. In fact, if you try to modify the `existing` data in an unsafe way in development, you will find that it has been deeply frozen using `Object.freeze`, so your attempted modifications will fail. + +When you're working with array-valued fields, especially when the full array might be very large, it's common to use field arguments to request the array from your GraphQL server in smaller chunks (or "pages"), which is a pattern called *pagination*. Pagination poses a challenge for Apollo Client, because the client needs to reconstruct the complete array from partial information. Fortunately, the `FieldPolicy` API, including `read` and `merge` functions, was designed with pagination in mind. + +Typically, pagination arguments will specify where to start in the array, using either a numeric offset or a starting ID, and the maximum number of elements to return in a single chunk. These arguments are important to consider when implementing a `merge` function for the field: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Agenda: { + fields: { + tasks: { + merge(existing: any[], incoming: any[], { args }) { + const merged = existing ? existing.slice(0) : []; + // Insert the incoming elements in the right places, according to args. + for (let i = args.offset; i < args.offset + args.limit; ++i) { + merged[i] = incoming[i - args.offset]; + } + return merged; + }, + + read(existing: any[], { args }) { + // If we read the field before any data has been written to the + // cache, this function will return undefined, which correctly + // indicates that the field is missing. + return existing && existing.slice( + args.offset, + args.offset + args.limit, + ); + }, + }, + }, + }, + }, +}); +``` + +As you can see in this example, your `read` function will often need to cooperate with your `merge` function, by handling the same arguments in the inverse direction. + +If you want to start after a specific task ID, rather than starting from `args.offset`, you might implement your `merge` and `read` functions as follows, using the `readField` helper function to examine existing task IDs: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Agenda: { + fields: { + tasks: { + merge(existing: any[], incoming: any[], { args, readField }) { + const merged = existing ? existing.slice(0) : []; + // Obtain a Set of all existing task IDs. + const existingIdSet = new Set( + merged.map(task => readField("id", task))); + // Remove incoming tasks already present in the existing data. + incoming = incoming.filter( + task => !existingIdSet.has(readField("id", task))); + // Find the index of the task just before the incoming page of tasks. + const afterIndex = merged.findIndex( + task => args.afterId === readField("id", task)); + if (afterIndex >= 0) { + // If we found afterIndex, insert incoming after that index. + merged.splice(afterIndex + 1, 0, ...incoming); + } else { + // Otherwise insert incoming at the end of the existing data. + merged.push(...incoming); + } + return merged; + }, + + read(existing: any[], { args, readField }) { + if (existing) { + const afterIndex = existing.findIndex( + task => args.afterId === readField("id", task)); + if (afterIndex >= 0) { + return existing.slice( + afterIndex + 1, + afterIndex + 1 + args.limit, + ); + } + } + }, + }, + }, + }, + }, +}); +``` + +As a reminder, if you call `readField(fieldName)`, it will return the value of that field from the current object. If you also pass an object or reference as the second argument, `readField` will read from that object instead of the current object. In this example, reading the `id` field from existing task objects allows us to deduplicate the `incoming` task data. + +The above code is getting complicated, but no more complicated than the underlying problem demands. It is, however, already far too complicated for Apollo Client to anticipate by default, which is why the `InMemoryCache` gives you complete control in the form of `read` and `merge` functions. + +Although the logic in your `merge` and `read` functions may become increasingly sophisticated over time, remember that this is the only place in your code where you need to describe this logic, and you can often extract common patterns into reusable helper functions that produce `FieldPolicy` objects, since nothing about this code is really specific to `Agenda`s or tasks: + +```ts +function afterIdLimitPaginatedFieldPolicy() { + return { + merge(existing: T[], incoming: T[], { args, readField }): T[] { + ... + }, + read(existing: T[], { args, readField }): T[] { + ... + }, + }; +} + +const cache = new InMemoryCache({ + typePolicies: { + Agenda: { + fields: { + tasks: afterIdLimitPaginatedFieldPolicy(), + }, + }, + }, +}); +``` + +Another common use case for `merge` functions is to combine nested objects that do not have IDs, but are known by the application developer to represent the same logical entity. Suppose that the `Book` type has an `author` field, which is an object containing information like the author's `name`, `primaryLanguage`, and `yearOfBirth`. + +TODO From 50886cee3b7d92fecad8a303313a6a14be13e7ba Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Thu, 16 Jan 2020 09:54:07 -0800 Subject: [PATCH 04/10] Edits to docs for TypePolicy and FieldPolicy (#5792) --- docs/source/caching/cache-configuration.md | 541 +++++++-------------- 1 file changed, 172 insertions(+), 369 deletions(-) diff --git a/docs/source/caching/cache-configuration.md b/docs/source/caching/cache-configuration.md index 2c70b68e4ec..0808c8d8cd6 100644 --- a/docs/source/caching/cache-configuration.md +++ b/docs/source/caching/cache-configuration.md @@ -4,7 +4,7 @@ title: Configuring the cache Apollo Client stores the results of its GraphQL queries in a normalized, in-memory cache. This enables your client to respond to future queries for the same data without sending unnecessary network requests. ->This article describes cache setup and configuration. To learn how to interact with cached data, see [Interacting with cached data](cache-interaction/). +>This article describes cache setup and configuration. To learn how to interact with cached data, see [Interacting with cached data](./cache-interaction). ## Installation @@ -52,7 +52,7 @@ The `InMemoryCache` **normalizes** query response objects before it saves them t 1. The cache [generates a unique ID](#generating-unique-identifiers) for every identifiable object included in the response. 2. The cache stores the objects by ID in a flat lookup table. -3. Whenever an object is stored with the same ID as an _existing_ object, the fields of those objects are _merged_. The new object overwrites the values of any fields that appear in both. +3. Whenever an incoming object is stored with the same ID as an _existing_ object, the fields of those objects are _merged_. If the incoming object and the existing object share any fields, the incoming object _overwrites_ the cached values for those fields. Fields that appear _only_ in the existing object _or_ the incoming object are preserved. Normalization constructs a partial copy of your data graph on your client, in a format that's optimized for reading and updating the graph as your application changes state. @@ -62,13 +62,13 @@ Normalization constructs a partial copy of your data graph on your client, in a #### Default identifier generation -By default, the `InMemoryCache` generates a unique identifier for any object that includes a `__typename` field by combining the object's `__typename` with its `id` or `_id` field (whichever is defined). These two values are separated by a colon (`:`). +By default, the `InMemoryCache` generates a unique identifier for any object that includes a `__typename` field. To do so, it combines the object's `__typename` with its `id` or `_id` field (whichever is defined). These two values are separated by a colon (`:`). For example, an object with a `__typename` of `Task` and an `id` of `14` is assigned a default identifier of `Task:14`. #### Customizing identifier generation by type -If one of your types defines its primary key with a field besides `id` or `_id`, you can customize how the `InMemoryCache` generates its unique identifier by defining a `TypePolicy` for the type. You specify all of your cache's `typePolicies` in [the `options` object you provide to the `InMemoryCache` constructor](#configuring-the-cache). +If one of your types defines its primary key with a field _besides_ `id` or `_id`, you can customize how the `InMemoryCache` generates unique identifiers for that type. To do so, you define `TypePolicy` for the type. You specify all of your cache's `typePolicies` in [the `options` object you provide to the `InMemoryCache` constructor](#configuring-the-cache). Include a `keyFields` field in relevant `TypePolicy` objects, like so: @@ -97,52 +97,21 @@ const cache = new InMemoryCache({ This example shows three `typePolicies`: one for a `Product` type, one for a `Person` type, and one for a `Book` type. Each `TypePolicy`'s `keyFields` array defines which fields on the type _together_ represent the type's primary key. -Note that the `Book` type uses a _subfield_ as part of its primary key. The `["name"]` item indicates that the `name` field of the _previous_ field in the array (`author`) is part of the primary key. The `Book`'s `author` field must be an object that includes a `name` field for this to be valid. +The `Book` type above uses a _subfield_ as part of its primary key. The `["name"]` item indicates that the `name` field of the _previous_ field in the array (`author`) is part of the primary key. The `Book`'s `author` field must be an object that includes a `name` field for this to be valid. -In the example above, the unique identifier string for a `Book` object has the following format: +In the example above, the resulting identifier string for a `Book` object has the following structure: ``` Book:{"title":"Fahrenheit 451","author":{"name":"Ray Bradbury"}} ``` -The object's primary key fields are always listed in the same order to ensure uniqueness. +An object's primary key fields are always listed in the same order to ensure uniqueness. -Note that these `keyFields` strings always refer to the actual field names as defined in your schema, meaning the ID computation is not sensitive to [field aliases](https://www.apollographql.com/docs/resources/graphql-glossary/#alias). This note is important if you ever attempt to use a function to implement `keyFields`: - -```ts -const cache = new InMemoryCache({ - typePolicies: { - Person: { - keyFields(responseObject, { typename, selectionSet, fragmentMap }) { - let id: string | null = null; - selectionSet.selections.some(selection => { - if (selection.kind === 'Field') { - // If you fail to take aliasing into account, your custom - // normalization is likely to break whenever a query contains - // an alias for key field. - const actualFieldName = selection.name.value; - const responseFieldName = (selection.alias || selection.name).value; - if (actualFieldName === 'socialSecurityNumber') { - id = `${typename}:${responseObject[responseFieldName]}`; - return true; - } - } else { - // Handle fragments using the fragmentMap... - } - return false; - }); - return id; - }, - }, - }, -}); -``` - -If this edge case seems obscure, you should probably steer clear of implementing your own `keyFields` functions, and instead stick to passing an array of strings for `keyFields`, so that you never have to worry about subtle bugs like these. As a general rule, the `typePolicies` API allows you to configure normalization behavior in one place, when you first create your cache, and does not require you to write your queries differently by aliasing fields or using directives. +Note that these `keyFields` strings always refer to the actual field names as defined in your schema, meaning the ID computation is not sensitive to [field aliases](https://www.apollographql.com/docs/resources/graphql-glossary/#alias). #### Customizing identifier generation globally -If you need to define a single fallback `keyFields` function that isn't specific to any particular `__typename`, the old `dataIdFromObject` function from Apollo Client 2.x is still supported: +If you need to define a single fallback `keyFields` function that isn't specific to any particular `__typename`, you can use the `dataIdFromObject` function that was introduced in Apollo Client 2.x: ```ts import { defaultDataIdFromObject } from '@apollo/client'; @@ -158,17 +127,17 @@ const cache = new InMemoryCache({ }); ``` -Notice how this function ends up needing to select different keys based on specific `object.__typename` strings anyway, so you might as well have used `keyFields` arrays for the `Product` and `Person` types via `typePolicies`. Also, this code is sensitive to aliasing mistakes, it does nothing to protect against undefined `object` properties, and accidentally using different key fields at different times could cause inconsistencies in the cache. +> The `dataIdFromObject` API is included in Apollo Client 3.0 to ease the transition from Apollo Client 2.x. The API might be removed in a future version of `@apollo/client`. -The `dataIdFromObject` API is meant to ease the transition from Apollo Client 2.x to 3.0, and may be removed in future versions of `@apollo/client`. +Notice that the above function still uses different logic to generate keys based on an object's `__typename`. In the above case, you might as well define `keyFields` arrays for the `Product` and `Person` types via `typePolicies`. Also, this code is sensitive to aliasing mistakes, it does nothing to protect against undefined `object` properties, and accidentally using different key fields at different times can cause inconsistencies in the cache. ### Disabling normalization -You can instruct the `InMemoryCache` _not_ to normalize objects of a certain type. This might make sense for metrics and other transient data that are identified by a timestamp and never receive updates. +You can instruct the `InMemoryCache` _not_ to normalize objects of a certain type. This can be useful for metrics and other transient data that's identified by a timestamp and never receives updates. -To disable normalization for a type, define a `TypePolicy` for the type (as shown in [Customizing identifier generation by type](#customizing-identifier-generation-by-type)), but set the policy's `keyFields` field to `false`. +To disable normalization for a type, define a `TypePolicy` for the type (as shown in [Customizing identifier generation by type](#customizing-identifier-generation-by-type)) and set the policy's `keyFields` field to `false`. -Objects that are not normalized are instead embedded within their _parent_ object in the cache. You cannot access these objects directly and must instead access them via their parent. +Objects that are not normalized are instead embedded within their _parent_ object in the cache. You can't access these objects directly, but you can access them via their parent. ## The `TypePolicy` type @@ -259,123 +228,43 @@ Compared to the `__typename`s of entity objects like `Book`s or `Person`s, which The final property within `TypePolicy` is the `fields` property, which is a map from string field names to `FieldPolicy` objects. The next section covers field policies in depth. -## Field policies +## Configuring individual fields -In addition to configuring the identification and normalization of `__typename`-having entity objects, a `TypePolicy` can provide policies for any of the fields supported by that type. +You can define a `FieldPolicy` object to customize cache interactions that involve a particular field. You nest `FieldPolicy` definitions within a corresponding `TypePolicy` definition. -Here are the `FieldPolicy` type and its related types: - -```ts -export type FieldPolicy = { - keyArgs?: KeySpecifier | KeyArgsFunction | false; - read?: FieldReadFunction; - merge?: FieldMergeFunction; -}; - -type KeyArgsFunction = ( - field: FieldNode, - context: { - typename: string; - variables: Record; - policies: Policies; - }, -) => string | null | void; - -interface FieldFunctionOptions { - args: Record | null; - field: string | FieldNode; - variables?: Record; - policies: Policies; - isReference(obj: any): obj is Reference; - toReference(obj: StoreObject): Reference; -} - -interface ReadFunctionOptions extends FieldFunctionOptions { - readField( - nameOrField: string | FieldNode, - foreignObjOrRef?: StoreObject | Reference, - ): Readonly; - storage: Record; - invalidate(): void; -} - -type FieldReadFunction = ( - existing: Readonly | undefined, - options: ReadFunctionOptions, -) => TResult; - -type FieldMergeFunction = ( - existing: Readonly | undefined, - incoming: Readonly, - options: FieldFunctionOptions, -) => TExisting; -``` - -In the sections below, we will break down these types with explanations and examples. - -### Key arguments - -Similar to the `keyFields` property of `TypePolicy` objects, the `keyArgs` property of `FieldPolicy` objects tells the cache which arguments passed to the field are "important" in the sense that their values (together with the enclosing entity object) determine the field's value. - -By default, the cache assumes all field arguments might be important, so it stores a separate field value for each unique combination of argument values it has received for that field. This is a safe policy because it ensures field values do not collide with each other if there was any difference in their arguments. However, this policy can also lead to unnecessary copies of field values, as well as missed opportunities for fields to share the same logical value even if their arguments were slightly different. - -For example, imagine you have a field that returns a secret value according to a given key, but also requires an access token to authenticate the request: - -```ts -query GetSecret { - secret(key: $secretKey, token: $secretAccessToken) { - message - } -} -``` - -As long as you have a valid access token, the value of this field depends only on the `key`. In other words, you won't get a different secret message back just because you used a different (valid) token. - -In cases like this, it would be wasteful and potentially inconsistent to let the access token factor into the storage of the field value, so you should let the cache know that only `key` is "important" by using the `keyArgs` option of the `FieldPolicy` for the `secret` field: +The following example defines a `FieldPolicy` for the `name` field of a `Person` type. The `FieldPolicy` includes a [`read` function](#the-read-function), which modifies what the cache returns whenever the field is queried: ```ts const cache = new InMemoryCache({ typePolicies: { - Query: { + Person: { fields: { - secret: { - keyArgs: ["key"], - }, + name: { + read(name) { + return name.toUpperCase(); + } + } }, }, }, }); ``` -That said, you might be able to assume the `token` is always the same, or you might not be worried about duplicating field values in the cache, so neglecting to specify `keyArgs: ["key"]` probably will not cause any major problems. Use `keyArgs` when it helps. +The use cases for `FieldPolicy` objects are described below. -On the other hand, perhaps you've requested the secret from the server using the access `token`, but you want various components on your page to be able to access the secret using only they `key`, without having to know the `token`. Storing the value in the cache using only the `key` makes this retrieval possible. +## Reducing cache duplicates by specifying key arguments -### Reading and merging fields +If a field accepts arguments, you can specify an array of `keyArgs` in the field's `FieldPolicy`. This array indicates which arguments are **key arguments** that are used to calculate the field's value. Specifying this array can help reduce the amount of duplicate data in your cache. -The GraphQL query language provides a uniform and ergonomic way of reading nested, tree-shaped data from a data graph. However, when you query the `InMemoryCache` rather than sending queries to a GraphQL server, you're reading data from a _client-side data graph_, which is almost always an incomplete copy of the data graph exposed by the server. - -To ensure your client-side data graph accurately reproduces the entities and relationships from your server-side data graph, while also extending the server graph with client-only information, you can define custom `read` and `merge` functions as part of any `FieldPolicy`. These functions will be invoked whenever the field is queried (`read`) or updated with new data (`merge`). - -#### Custom `read` functions - -The most basic custom `read` function simply returns existing data from the cache, without modification: +Let's say your schema's `Query` type includes a `monthForNumber` field that returns the details of a `Month` type, given a provided `number` argument (January for `1` and so on). The `number` argument is a key argument for this field, because it is used when calculating the field's result: ```ts const cache = new InMemoryCache({ typePolicies: { - Person: { + Query: { fields: { - name: { - read(name: string) { - return name; - }, - }, - - // Since read functions are so common, you can collapse the FieldPolicy - // into a single method, if you only need to define a read function. - age(age: number) { - return age; + monthForNumber: { + keyArgs: ["number"], }, }, }, @@ -383,83 +272,33 @@ const cache = new InMemoryCache({ }); ``` -Neither of these `read` functions really needs to be defined, since they both do exactly what the cache already does by default: they return existing cache data. +An example of a _non-key_ argument is an access token, which is used to authorize a query but _not_ to calculate its result. If `monthForNumber` accepts an `accessToken` argument, the value of that argument does _not_ affect the details of the returned `Month` type. -Things start to get interesting when you define a `read` function for data that does not exist in the cache: +By default, the cache stores a separate value for _every unique combination of argument values you provide when querying a particular field_. When you specify a field's key arguments, the cache understands that any _non_-key arguments don't affect that field's value. Consequently, if you execute two different queries with the `monthForNumber` field, passing the _same_ `number` argument but _different_ `accessToken` arguments, the second query response will overwrite the first, because both invocations have the same key arguments. -```ts -const cache = new InMemoryCache({ - typePolicies: { - Person: { - fields: { - userId() { - return localStorage.loggedInUserId; - }, - }, - }, - }, -}); -``` +## Customizing field reads and writes -Or when you want to tweak the existing data slightly: +You can customize the cache's behavior when you read or write a particular field. For example, you might want the cache to return a particular default value for a field when that field isn't present in the cache. -```ts -const cache = new InMemoryCache({ - typePolicies: { - Person: { - fields: { - age(floatingPointAge: number) { - return Math.round(floatingPointAge); - }, - }, - }, - }, -}); -``` +To accomplish this, you can define `read` and `merge` functions as part of any field's `FieldPolicy`. These functions are called whenever the associated field is queried (`read`) or updated (`merge`) in the cache. -Or when you want to provide a default value for potentially nonexistent cache data: +### The `read` function -```ts -const cache = new InMemoryCache({ - typePolicies: { - Person: { - fields: { - name(name = "Jane Doe") { - return name; - }, +If you define a `read` function for a field, the cache calls that function whenever your client queries for the field. In the query response, the field is populated with the `read` function's return value, _instead of the field's cached value_. - age(age = /* Median American age: */ 38.1) { - return age; - }, - }, - }, - }, -}); -``` +The `read` function takes the field's cached value as a parameter, so you can use it to help determine the function's return value. -Or when you want to define computed fields in terms of other fields: +The following `read` function assigns a default value of `UNKNOWN NAME` to the `name` field of a `Person` type, if the actual value is not available in the cache. In all other cases, the cached value is returned. ```ts const cache = new InMemoryCache({ typePolicies: { Person: { fields: { - ageInDogYears(_, { readField }) { - // In TypeScript it can be useful to coerce the field value to a known - // type, such as number: - return readField("age") / 7; - }, - - // Since this field does not exist in the cache, we ignore the non-existent - // existing data given by the first parameter. - fullName(_, { readField }) { - const firstName = readField("firstName"); - const lastName = readField("lastName"); - if (firstName && lastName) { - return `${firstName} ${firstName}`; + name: { + read(name = "UNKNOWN NAME") { + return name; } - // Returning undefined indicates the fullName field is missing, because - // one or more of its dependencies was not available. }, }, }, @@ -467,28 +306,22 @@ const cache = new InMemoryCache({ }); ``` -You can even read fields from other entities in the cache: +If a field accepts arguments, its associated `read` function is passed the values of those arguments. The following `read` function checks to see if the `maxLength` argument is provided when the `name` field is queried. If it is, the function returns only the first `maxLength` characters of the person's name. Otherwise, the person's full name is returned. ```ts const cache = new InMemoryCache({ typePolicies: { Person: { fields: { - youngestFriend(_, { readField }) { - let youngestFriend: object | undefined; - let minAge = Infinity; - const friends = readField("friends") || []; - friends.forEach(friend => { - // Passing an object or Reference as the second argument to readField - // causes the field value to be read from that entity instead of the - // current entity. - const friendAge = readField("age", friend); - if (friendAge < minAge) { - minAge = friendAge; - youngestFriend = friend; - } - }); - return youngestFriend; + + // If a field's TypePolicy would only include a read function, + // you can optionally define the function like so, instead of + // nesting it inside an object as shown in the example above. + name(name: string, { args }) { + if (args && typeof args.maxLength === "number") { + return name.substring(0, args.maxLength); + } + return name; }, }, }, @@ -496,55 +329,15 @@ const cache = new InMemoryCache({ }); ``` -If the field takes arguments, a `read` function can be used to interpret those arguments: +You can define a `read` function for a field that isn't even defined in your schema. For example, the following `read` function enables you to query a `userId` field that is always populated with locally stored data: ```ts const cache = new InMemoryCache({ typePolicies: { Person: { fields: { - // Optionally allow asking for the person's age in any units. - age(ageInYears: number, { args }) { - if (args && typeof args.units === "string") { - return convertUnits(ageInYears, "years", args.units); - } - return ageInYears; - }, - - // Sorting/filtering/pagination of the person's list of friends. - friends(friendRefs: Reference[], { args, readField }) { - if (args && typeof args.sortBy === "string") { - // Sort the refs first, if requested. Note that friendRefs is an - // immutable array, which is why friendRefs.slice(0) is necessary. - friendRefs = friendRefs.slice(0).sort((a, b) => compareBy( - args.sortBy, - readField(args.sortBy, a), - readField(args.sortBy, b), - )); - } - - if (args && typeof args.limit === "number") { - // Offset/limit-based pagination: - if (typeof args.offset === "number") { - return friendRefs.slice(args.offset, args.offset + args.limit); - } - - // Other kinds of pagination: - if (typeof args.startId === "string") { - const offset = friendRefs.findIndex( - ref => readField("id", ref) === args.startId); - if (offset >= 0) { - return friendRefs.slice(offset, offset + args.limit); - } - } - - // Returning undefined indicates that the field is missing. - // Throwing an exception might also be appropriate here. - return; - } - - // Return the whole list if no pagination arguments provided. - return friendRefs; + userId() { + return localStorage.loggedInUserId; }, }, }, @@ -552,90 +345,23 @@ const cache = new InMemoryCache({ }); ``` -Now that you've gotten a taste for the power and flexibility of `read` functions, let's have a look at all the options that are provided by the second parameter: +> Note that to query for a field that is only defined locally, your query should [include the `@client` directive](/data/local-state/#querying-local-state) on that field so that Apollo Client doesn't include it in requests to your GraphQL server. -```ts -// These options are common to both read and merge functions: -interface FieldFunctionOptions { - // The final argument values passed to the field, after applying variables. - // If no arguments were provided, this property will be null. - args: Record | null; +Other use cases for a `read` function include: - // The name of the field, equal to options.field.name.value when - // options.field is available. Useful if you reuse the same function for - // multiple fields, and you need to know which field you're currently - // processing. Always a string, even when options.field is null. - fieldName: string; +* Transforming cached data to suit your client's needs, such as rounding floating-point values to the nearest integer +* Deriving local-only fields from one or more schema fields on the same object (such as deriving an `age` field from a `birthDate` field) +* Deriving local-only fields from one or more schema fields across _multiple_ objects - // The FieldNode object used to read this field. Useful if you need to - // know about other attributes of the field, such as its directives. This - // option will be null when a string was passed to options.readField. - field: FieldNode | null; - - // The variables that were provided when reading the query that contained - // this field. Possibly undefined, if no variables were provided. - variables?: Record; - - // Utilities for handling { __ref: string } references. - isReference(obj: any): obj is Reference; - toReference(obj: StoreObject): Reference; - - // A reference to the Policies object created by passing typePolicies to - // the InMemoryCache constructor, for advanced/internal use. - policies: Policies; -} - -// These options are specific to read functions: -interface ReadFunctionOptions extends FieldFunctionOptions { - // Helper function for reading other fields within the current object. - // If a foreign object or reference is provided, the field will be read - // from that object instead of the current object, so this function can - // be used (together with isReference) to examine the cache outside the - // current object. If a FieldNode is passed instead of a string, and - // that FieldNode has arguments, the same options.variables will be used - // to compute the argument values. Note that this function will invoke - // custom read functions for other fields, if defined. Always returns - // immutable data (enforced with Object.freeze in development). - readField( - nameOrField: string | FieldNode, - foreignObjOrRef?: StoreObject | Reference, - ): Readonly; +For a full list of the options provided to the `read` function, see the [API reference](#fieldpolicy-api-reference). You will almost never need to use all of these options, but each one has an important role when reading fields from the cache. - // A handy place to put field-specific data that you want to survive - // across multiple read function calls. Useful for caching. - storage: Record; +### The `merge` function - // Call this function to invalidate any cached queries that previously - // consumed this field. If you use options.storage as a cache, setting a - // new value in the cache and then calling options.invalidate() can be a - // good way to deliver asynchronous results. - invalidate(): void; -} -``` +If you define a `merge` function for a field, the cache calls that function whenever the field is about to be written with an incoming value (such as from your GraphQL server). When the write occurs, the field's new value is set to the `merge` function's return value, _instead of the original incoming value_. -You will almost never need to use all of these options at the same time, but each one has an important role to play when reading unusual fields from the cache. +#### Merging arrays -#### Custom `merge` functions - -If a `read` function can customize what happens when a field within an entity object is read from the cache, what about writing fields into the cache? - -The short answer is that a `FieldPolicy` object can contain a custom `merge` function that takes the field's existing value and an incoming value, and merges them together into a new value that will be stored for that field. - -However, in order to understand when you might need to define a custom `merge` function, it's important to understand how the cache merges data by default, without any customization: - -1. When `cache.writeQuery({ query, data })` is called, the cache traverses `query` and `data` in parallel to find any objects within `data` that have a `__typename` and the necessary fields to compute a unique identifier for the object, using `keyFields` or `dataIdFromObject`. Not all objects have this information, but those that do will be stored in a flat `Map`-like data structure, with the ID strings as keys, and the objects as values. - - > Note: the root `data` object is always assigned a special `ROOT_QUERY` ID, since it contains root `Query` fields. This is why you do not have to specify `keyFields` for the `Query` type. - -2. If the normalized map already contains an entity object with the same ID, the fields of the new object will be automatically shallow-merged into the existing fields, _replacing_ any fields that overlap. This kind of merging happens automatically, and makes sense in most cases because we know the objects have the same ID, which means they represent the same logical entity. - -3. By default, the cache does not attempt to merge the _values_ of top-level entity fields, even if those values are objects or arrays. If an ID could be computed for a nested object (or the object elements of an array), the object would already have been written into the cache with that ID (see step 1), and a special `{ __ref: }` object referring to the normalized entity would become the value of the field. When a field has a scalar value, or when no ID can be computed for an object value, the value is treated as opaque data, and no attempt is made to merge it with other data. - -To recap: the top-level fields of normalized entity objects are shallow-merged together, but no additional merging happens by default. Objects are never merged together unless they have the same ID, even if it's possible that they might represent the same logical entity, because mixing fields from different entities is a recipe for data graph inconsistencies. - -If this default behavior is insufficient for your needs, because you want to prevent existing field values from being completely replaced, or you want to translate the incoming data somehow before it is stored in the cache, that's when you should consider writing a custom `merge` function for the field. - -A simple but common use case for `merge` functions is to define what happens when an array-valued field is about to be overwritten by a new array. Often, it would be better to concatenate the arrays, rather than replacing the existing array: +A common use case for a `merge` function is to define how to write to a field that holds an array. By default, the field's existing array is _completely replaced_ by the incoming array. Often, it's preferable to _concatenate_ the two arrays instead, like so: ```ts const cache = new InMemoryCache({ @@ -653,33 +379,26 @@ const cache = new InMemoryCache({ }); ``` -Note that `existing` will be undefined the very first time the `merge` function is called, since the cache does not contain any data for this field (within this particular object) yet. The `existing = []` default parameter style is a convenient way to handle this case. +Note that `existing` is undefined the very first time this function is called for a given instance of the field, because the cache does not yet contain any data for the field. Providing the `existing = []` default parameter is a convenient way to handle this case. -You might be tempted to write this function in a more destructive, less "functional" style: +> Your `merge` function **cannot** push the `incoming` array directly onto the `existing` array. It must instead return a new array to prevent potential errors. In development mode, Apollo Client prevents unintended modification of the `existing` data with `Object.freeze`. -```ts -const cache = new InMemoryCache({ - typePolicies: { - Agenda: { - fields: { - tasks: { - merge(existing = [], incoming: any[]) { - // Not allowed! - existing.push(...incoming); - return existing; - }, - }, - }, - }, - }, -}); -``` +#### Merging non-normalized objects + +Another common use case for `merge` functions is to combine nested objects that do not have IDs but definitely represent the same underlying object. Suppose that a `Book` type has an `author` field, which is an object containing information like the author's `name`, `primaryLanguage`, and `yearOfBirth`. -However, modifying existing data in the cache is forbidden, because altering the contents of cached objects without changing their references can prevent the cache from reporting changes to your application in some cases, and also interferes with the ability of the cache to produce immutable snapshots using the `cache.extract()` method. In fact, if you try to modify the `existing` data in an unsafe way in development, you will find that it has been deeply frozen using `Object.freeze`, so your attempted modifications will fail. +TODO + +### Handling pagination + +When a field holds an array, it's often useful to [paginate](/data/pagination/) that array's results, because the total result set can be arbitrarily large. -When you're working with array-valued fields, especially when the full array might be very large, it's common to use field arguments to request the array from your GraphQL server in smaller chunks (or "pages"), which is a pattern called *pagination*. Pagination poses a challenge for Apollo Client, because the client needs to reconstruct the complete array from partial information. Fortunately, the `FieldPolicy` API, including `read` and `merge` functions, was designed with pagination in mind. +Typically, a query includes pagination arguments that specify: -Typically, pagination arguments will specify where to start in the array, using either a numeric offset or a starting ID, and the maximum number of elements to return in a single chunk. These arguments are important to consider when implementing a `merge` function for the field: +* Where to start in the array, using either a numeric offset or a starting ID +* The maximum number of elements to return in a single "page" + +If you implement pagination for a field, it's important to keep pagination arguments in mind if you then implement `read` and `merge` functions for the field: ```ts const cache = new InMemoryCache({ @@ -712,9 +431,9 @@ const cache = new InMemoryCache({ }); ``` -As you can see in this example, your `read` function will often need to cooperate with your `merge` function, by handling the same arguments in the inverse direction. +As this example shows, your `read` function often needs to cooperate with your `merge` function, by handling the same arguments in the inverse direction. -If you want to start after a specific task ID, rather than starting from `args.offset`, you might implement your `merge` and `read` functions as follows, using the `readField` helper function to examine existing task IDs: +If you want a given "page" to start after a specific entity ID instead of starting from `args.offset`, you can implement your `merge` and `read` functions as follows, using the `readField` helper function to examine existing task IDs: ```ts const cache = new InMemoryCache({ @@ -762,11 +481,9 @@ const cache = new InMemoryCache({ }); ``` -As a reminder, if you call `readField(fieldName)`, it will return the value of that field from the current object. If you also pass an object or reference as the second argument, `readField` will read from that object instead of the current object. In this example, reading the `id` field from existing task objects allows us to deduplicate the `incoming` task data. - -The above code is getting complicated, but no more complicated than the underlying problem demands. It is, however, already far too complicated for Apollo Client to anticipate by default, which is why the `InMemoryCache` gives you complete control in the form of `read` and `merge` functions. +Note that if you call `readField(fieldName)`, it returns the value of the specified field from the current object. If you pass an object as a _second_ argument to `readField`, (e.g., `readField("id", task)`), `readField` instead reads the specified field from the specified object. In the above example, reading the `id` field from existing `Task` objects allows us to deduplicate the `incoming` task data. -Although the logic in your `merge` and `read` functions may become increasingly sophisticated over time, remember that this is the only place in your code where you need to describe this logic, and you can often extract common patterns into reusable helper functions that produce `FieldPolicy` objects, since nothing about this code is really specific to `Agenda`s or tasks: +The pagination code above is complicated, but after you define it for your preferred pagination strategy, you can reuse it for every field that uses that strategy, regardless of the field's type. For example: ```ts function afterIdLimitPaginatedFieldPolicy() { @@ -791,6 +508,92 @@ const cache = new InMemoryCache({ }); ``` -Another common use case for `merge` functions is to combine nested objects that do not have IDs, but are known by the application developer to represent the same logical entity. Suppose that the `Book` type has an `author` field, which is an object containing information like the author's `name`, `primaryLanguage`, and `yearOfBirth`. +## `FieldPolicy` API reference -TODO +Here are the definitions for the `FieldPolicy` type and its related types: + +```ts +export type FieldPolicy = { + keyArgs?: KeySpecifier | KeyArgsFunction | false; + read?: FieldReadFunction; + merge?: FieldMergeFunction; +}; + +type KeyArgsFunction = ( + field: FieldNode, + context: { + typename: string; + variables: Record; + policies: Policies; + }, +) => string | null | void; + +// These options are common to both read and merge functions: +interface FieldFunctionOptions { + // The final argument values passed to the field, after applying variables. + // If no arguments were provided, this property will be null. + args: Record | null; + + // The name of the field, equal to options.field.name.value when + // options.field is available. Useful if you reuse the same function for + // multiple fields, and you need to know which field you're currently + // processing. Always a string, even when options.field is null. + fieldName: string; + + // The FieldNode object used to read this field. Useful if you need to + // know about other attributes of the field, such as its directives. This + // option will be null when a string was passed to options.readField. + field: FieldNode | null; + + // The variables that were provided when reading the query that contained + // this field. Possibly undefined, if no variables were provided. + variables?: Record; + + // Utilities for handling { __ref: string } references. + isReference(obj: any): obj is Reference; + toReference(obj: StoreObject): Reference; + + // A reference to the Policies object created by passing typePolicies to + // the InMemoryCache constructor, for advanced/internal use. + policies: Policies; +} + +// These options are specific to read functions: +interface ReadFunctionOptions extends FieldFunctionOptions { + // Helper function for reading other fields within the current object. + // If a foreign object or reference is provided, the field will be read + // from that object instead of the current object, so this function can + // be used (together with isReference) to examine the cache outside the + // current object. If a FieldNode is passed instead of a string, and + // that FieldNode has arguments, the same options.variables will be used + // to compute the argument values. Note that this function will invoke + // custom read functions for other fields, if defined. Always returns + // immutable data (enforced with Object.freeze in development). + readField( + nameOrField: string | FieldNode, + foreignObjOrRef?: StoreObject | Reference, + ): Readonly; + + // A handy place to put field-specific data that you want to survive + // across multiple read function calls. Useful for caching. + storage: Record; + + // Call this function to invalidate any cached queries that previously + // consumed this field. If you use options.storage as a cache, setting a + // new value in the cache and then calling options.invalidate() can be a + // good way to deliver asynchronous results. + invalidate(): void; +} +``` + +type FieldReadFunction = ( + existing: Readonly | undefined, + options: ReadFunctionOptions, +) => TResult; + +type FieldMergeFunction = ( + existing: Readonly | undefined, + incoming: Readonly, + options: FieldFunctionOptions, +) => TExisting; +``` From 268b54ee5264d7443eaa07e61611ca70a6b5f070 Mon Sep 17 00:00:00 2001 From: Stephen Barlow Date: Fri, 17 Jan 2020 08:55:53 -0800 Subject: [PATCH 05/10] Move FieldPolicy stuff into its own article (#5800) --- docs/gatsby-config.js | 5 +- docs/source/caching/cache-configuration.md | 372 +------------------- docs/source/caching/cache-field-behavior.md | 370 +++++++++++++++++++ 3 files changed, 375 insertions(+), 372 deletions(-) create mode 100644 docs/source/caching/cache-field-behavior.md diff --git a/docs/gatsby-config.js b/docs/gatsby-config.js index f29c813dd8f..39e62f52e84 100644 --- a/docs/gatsby-config.js +++ b/docs/gatsby-config.js @@ -40,7 +40,10 @@ module.exports = { 'data/fragments', 'data/error-handling', ], - Caching: ['caching/cache-configuration', 'caching/cache-interaction'], + Caching: [ + 'caching/cache-configuration', + 'caching/cache-field-behavior', + 'caching/cache-interaction'], 'Development & Testing': [ 'development-testing/static-typing', 'development-testing/testing', diff --git a/docs/source/caching/cache-configuration.md b/docs/source/caching/cache-configuration.md index 0808c8d8cd6..e6035568d14 100644 --- a/docs/source/caching/cache-configuration.md +++ b/docs/source/caching/cache-configuration.md @@ -226,374 +226,4 @@ Compared to the `__typename`s of entity objects like `Book`s or `Person`s, which ### The `fields` property -The final property within `TypePolicy` is the `fields` property, which is a map from string field names to `FieldPolicy` objects. The next section covers field policies in depth. - -## Configuring individual fields - -You can define a `FieldPolicy` object to customize cache interactions that involve a particular field. You nest `FieldPolicy` definitions within a corresponding `TypePolicy` definition. - -The following example defines a `FieldPolicy` for the `name` field of a `Person` type. The `FieldPolicy` includes a [`read` function](#the-read-function), which modifies what the cache returns whenever the field is queried: - -```ts -const cache = new InMemoryCache({ - typePolicies: { - Person: { - fields: { - name: { - read(name) { - return name.toUpperCase(); - } - } - }, - }, - }, -}); -``` - -The use cases for `FieldPolicy` objects are described below. - -## Reducing cache duplicates by specifying key arguments - -If a field accepts arguments, you can specify an array of `keyArgs` in the field's `FieldPolicy`. This array indicates which arguments are **key arguments** that are used to calculate the field's value. Specifying this array can help reduce the amount of duplicate data in your cache. - -Let's say your schema's `Query` type includes a `monthForNumber` field that returns the details of a `Month` type, given a provided `number` argument (January for `1` and so on). The `number` argument is a key argument for this field, because it is used when calculating the field's result: - -```ts -const cache = new InMemoryCache({ - typePolicies: { - Query: { - fields: { - monthForNumber: { - keyArgs: ["number"], - }, - }, - }, - }, -}); -``` - -An example of a _non-key_ argument is an access token, which is used to authorize a query but _not_ to calculate its result. If `monthForNumber` accepts an `accessToken` argument, the value of that argument does _not_ affect the details of the returned `Month` type. - -By default, the cache stores a separate value for _every unique combination of argument values you provide when querying a particular field_. When you specify a field's key arguments, the cache understands that any _non_-key arguments don't affect that field's value. Consequently, if you execute two different queries with the `monthForNumber` field, passing the _same_ `number` argument but _different_ `accessToken` arguments, the second query response will overwrite the first, because both invocations have the same key arguments. - -## Customizing field reads and writes - -You can customize the cache's behavior when you read or write a particular field. For example, you might want the cache to return a particular default value for a field when that field isn't present in the cache. - -To accomplish this, you can define `read` and `merge` functions as part of any field's `FieldPolicy`. These functions are called whenever the associated field is queried (`read`) or updated (`merge`) in the cache. - -### The `read` function - -If you define a `read` function for a field, the cache calls that function whenever your client queries for the field. In the query response, the field is populated with the `read` function's return value, _instead of the field's cached value_. - -The `read` function takes the field's cached value as a parameter, so you can use it to help determine the function's return value. - -The following `read` function assigns a default value of `UNKNOWN NAME` to the `name` field of a `Person` type, if the actual value is not available in the cache. In all other cases, the cached value is returned. - -```ts -const cache = new InMemoryCache({ - typePolicies: { - Person: { - fields: { - name: { - read(name = "UNKNOWN NAME") { - return name; - } - }, - }, - }, - }, -}); -``` - -If a field accepts arguments, its associated `read` function is passed the values of those arguments. The following `read` function checks to see if the `maxLength` argument is provided when the `name` field is queried. If it is, the function returns only the first `maxLength` characters of the person's name. Otherwise, the person's full name is returned. - -```ts -const cache = new InMemoryCache({ - typePolicies: { - Person: { - fields: { - - // If a field's TypePolicy would only include a read function, - // you can optionally define the function like so, instead of - // nesting it inside an object as shown in the example above. - name(name: string, { args }) { - if (args && typeof args.maxLength === "number") { - return name.substring(0, args.maxLength); - } - return name; - }, - }, - }, - }, -}); -``` - -You can define a `read` function for a field that isn't even defined in your schema. For example, the following `read` function enables you to query a `userId` field that is always populated with locally stored data: - -```ts -const cache = new InMemoryCache({ - typePolicies: { - Person: { - fields: { - userId() { - return localStorage.loggedInUserId; - }, - }, - }, - }, -}); -``` - -> Note that to query for a field that is only defined locally, your query should [include the `@client` directive](/data/local-state/#querying-local-state) on that field so that Apollo Client doesn't include it in requests to your GraphQL server. - -Other use cases for a `read` function include: - -* Transforming cached data to suit your client's needs, such as rounding floating-point values to the nearest integer -* Deriving local-only fields from one or more schema fields on the same object (such as deriving an `age` field from a `birthDate` field) -* Deriving local-only fields from one or more schema fields across _multiple_ objects - -For a full list of the options provided to the `read` function, see the [API reference](#fieldpolicy-api-reference). You will almost never need to use all of these options, but each one has an important role when reading fields from the cache. - -### The `merge` function - -If you define a `merge` function for a field, the cache calls that function whenever the field is about to be written with an incoming value (such as from your GraphQL server). When the write occurs, the field's new value is set to the `merge` function's return value, _instead of the original incoming value_. - -#### Merging arrays - -A common use case for a `merge` function is to define how to write to a field that holds an array. By default, the field's existing array is _completely replaced_ by the incoming array. Often, it's preferable to _concatenate_ the two arrays instead, like so: - -```ts -const cache = new InMemoryCache({ - typePolicies: { - Agenda: { - fields: { - tasks: { - merge(existing = [], incoming: any[]) { - return [...existing, ...incoming]; - }, - }, - }, - }, - }, -}); -``` - -Note that `existing` is undefined the very first time this function is called for a given instance of the field, because the cache does not yet contain any data for the field. Providing the `existing = []` default parameter is a convenient way to handle this case. - -> Your `merge` function **cannot** push the `incoming` array directly onto the `existing` array. It must instead return a new array to prevent potential errors. In development mode, Apollo Client prevents unintended modification of the `existing` data with `Object.freeze`. - -#### Merging non-normalized objects - -Another common use case for `merge` functions is to combine nested objects that do not have IDs but definitely represent the same underlying object. Suppose that a `Book` type has an `author` field, which is an object containing information like the author's `name`, `primaryLanguage`, and `yearOfBirth`. - -TODO - -### Handling pagination - -When a field holds an array, it's often useful to [paginate](/data/pagination/) that array's results, because the total result set can be arbitrarily large. - -Typically, a query includes pagination arguments that specify: - -* Where to start in the array, using either a numeric offset or a starting ID -* The maximum number of elements to return in a single "page" - -If you implement pagination for a field, it's important to keep pagination arguments in mind if you then implement `read` and `merge` functions for the field: - -```ts -const cache = new InMemoryCache({ - typePolicies: { - Agenda: { - fields: { - tasks: { - merge(existing: any[], incoming: any[], { args }) { - const merged = existing ? existing.slice(0) : []; - // Insert the incoming elements in the right places, according to args. - for (let i = args.offset; i < args.offset + args.limit; ++i) { - merged[i] = incoming[i - args.offset]; - } - return merged; - }, - - read(existing: any[], { args }) { - // If we read the field before any data has been written to the - // cache, this function will return undefined, which correctly - // indicates that the field is missing. - return existing && existing.slice( - args.offset, - args.offset + args.limit, - ); - }, - }, - }, - }, - }, -}); -``` - -As this example shows, your `read` function often needs to cooperate with your `merge` function, by handling the same arguments in the inverse direction. - -If you want a given "page" to start after a specific entity ID instead of starting from `args.offset`, you can implement your `merge` and `read` functions as follows, using the `readField` helper function to examine existing task IDs: - -```ts -const cache = new InMemoryCache({ - typePolicies: { - Agenda: { - fields: { - tasks: { - merge(existing: any[], incoming: any[], { args, readField }) { - const merged = existing ? existing.slice(0) : []; - // Obtain a Set of all existing task IDs. - const existingIdSet = new Set( - merged.map(task => readField("id", task))); - // Remove incoming tasks already present in the existing data. - incoming = incoming.filter( - task => !existingIdSet.has(readField("id", task))); - // Find the index of the task just before the incoming page of tasks. - const afterIndex = merged.findIndex( - task => args.afterId === readField("id", task)); - if (afterIndex >= 0) { - // If we found afterIndex, insert incoming after that index. - merged.splice(afterIndex + 1, 0, ...incoming); - } else { - // Otherwise insert incoming at the end of the existing data. - merged.push(...incoming); - } - return merged; - }, - - read(existing: any[], { args, readField }) { - if (existing) { - const afterIndex = existing.findIndex( - task => args.afterId === readField("id", task)); - if (afterIndex >= 0) { - return existing.slice( - afterIndex + 1, - afterIndex + 1 + args.limit, - ); - } - } - }, - }, - }, - }, - }, -}); -``` - -Note that if you call `readField(fieldName)`, it returns the value of the specified field from the current object. If you pass an object as a _second_ argument to `readField`, (e.g., `readField("id", task)`), `readField` instead reads the specified field from the specified object. In the above example, reading the `id` field from existing `Task` objects allows us to deduplicate the `incoming` task data. - -The pagination code above is complicated, but after you define it for your preferred pagination strategy, you can reuse it for every field that uses that strategy, regardless of the field's type. For example: - -```ts -function afterIdLimitPaginatedFieldPolicy() { - return { - merge(existing: T[], incoming: T[], { args, readField }): T[] { - ... - }, - read(existing: T[], { args, readField }): T[] { - ... - }, - }; -} - -const cache = new InMemoryCache({ - typePolicies: { - Agenda: { - fields: { - tasks: afterIdLimitPaginatedFieldPolicy(), - }, - }, - }, -}); -``` - -## `FieldPolicy` API reference - -Here are the definitions for the `FieldPolicy` type and its related types: - -```ts -export type FieldPolicy = { - keyArgs?: KeySpecifier | KeyArgsFunction | false; - read?: FieldReadFunction; - merge?: FieldMergeFunction; -}; - -type KeyArgsFunction = ( - field: FieldNode, - context: { - typename: string; - variables: Record; - policies: Policies; - }, -) => string | null | void; - -// These options are common to both read and merge functions: -interface FieldFunctionOptions { - // The final argument values passed to the field, after applying variables. - // If no arguments were provided, this property will be null. - args: Record | null; - - // The name of the field, equal to options.field.name.value when - // options.field is available. Useful if you reuse the same function for - // multiple fields, and you need to know which field you're currently - // processing. Always a string, even when options.field is null. - fieldName: string; - - // The FieldNode object used to read this field. Useful if you need to - // know about other attributes of the field, such as its directives. This - // option will be null when a string was passed to options.readField. - field: FieldNode | null; - - // The variables that were provided when reading the query that contained - // this field. Possibly undefined, if no variables were provided. - variables?: Record; - - // Utilities for handling { __ref: string } references. - isReference(obj: any): obj is Reference; - toReference(obj: StoreObject): Reference; - - // A reference to the Policies object created by passing typePolicies to - // the InMemoryCache constructor, for advanced/internal use. - policies: Policies; -} - -// These options are specific to read functions: -interface ReadFunctionOptions extends FieldFunctionOptions { - // Helper function for reading other fields within the current object. - // If a foreign object or reference is provided, the field will be read - // from that object instead of the current object, so this function can - // be used (together with isReference) to examine the cache outside the - // current object. If a FieldNode is passed instead of a string, and - // that FieldNode has arguments, the same options.variables will be used - // to compute the argument values. Note that this function will invoke - // custom read functions for other fields, if defined. Always returns - // immutable data (enforced with Object.freeze in development). - readField( - nameOrField: string | FieldNode, - foreignObjOrRef?: StoreObject | Reference, - ): Readonly; - - // A handy place to put field-specific data that you want to survive - // across multiple read function calls. Useful for caching. - storage: Record; - - // Call this function to invalidate any cached queries that previously - // consumed this field. If you use options.storage as a cache, setting a - // new value in the cache and then calling options.invalidate() can be a - // good way to deliver asynchronous results. - invalidate(): void; -} -``` - -type FieldReadFunction = ( - existing: Readonly | undefined, - options: ReadFunctionOptions, -) => TResult; - -type FieldMergeFunction = ( - existing: Readonly | undefined, - incoming: Readonly, - options: FieldFunctionOptions, -) => TExisting; -``` +The final property within `TypePolicy` is the `fields` property, which is a map from string field names to `FieldPolicy` objects. For more information on this field, see [Customizing the behavior of cached fields](./cache-field-behavior). diff --git a/docs/source/caching/cache-field-behavior.md b/docs/source/caching/cache-field-behavior.md new file mode 100644 index 00000000000..5a7ca2c842f --- /dev/null +++ b/docs/source/caching/cache-field-behavior.md @@ -0,0 +1,370 @@ +--- +title: Customizing the behavior of cached fields +--- + +You can customize how individual fields in the Apollo Client cache are read and written. To do so, you define a `FieldPolicy` object for a given field. You nest a `FieldPolicy` object within whatever [`TypePolicy` object](./cache-configuration/#the-typepolicy-type) corresponds to the type that contains the field. + +The following example defines a `FieldPolicy` for the `name` field of a `Person` type. The `FieldPolicy` includes a [`read` function](#the-read-function), which modifies what the cache returns whenever the field is queried: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + name: { + read(name) { + return name.toUpperCase(); + } + } + }, + }, + }, +}); +``` + +The use cases for `FieldPolicy` objects are described below. + +## Reducing cache duplicates by specifying key arguments + +If a field accepts arguments, you can specify an array of `keyArgs` in the field's `FieldPolicy`. This array indicates which arguments are **key arguments** that are used to calculate the field's value. Specifying this array can help reduce the amount of duplicate data in your cache. + +Let's say your schema's `Query` type includes a `monthForNumber` field that returns the details of a `Month` type, given a provided `number` argument (January for `1` and so on). The `number` argument is a key argument for this field, because it is used when calculating the field's result: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + monthForNumber: { + keyArgs: ["number"], + }, + }, + }, + }, +}); +``` + +An example of a _non-key_ argument is an access token, which is used to authorize a query but _not_ to calculate its result. If `monthForNumber` accepts an `accessToken` argument, the value of that argument does _not_ affect the details of the returned `Month` type. + +By default, the cache stores a separate value for _every unique combination of argument values you provide when querying a particular field_. When you specify a field's key arguments, the cache understands that any _non_-key arguments don't affect that field's value. Consequently, if you execute two different queries with the `monthForNumber` field, passing the _same_ `number` argument but _different_ `accessToken` arguments, the second query response will overwrite the first, because both invocations have the same key arguments. + +## Customizing field reads and writes + +You can customize the cache's behavior when you read or write a particular field. For example, you might want the cache to return a particular default value for a field when that field isn't present in the cache. + +To accomplish this, you can define `read` and `merge` functions as part of any field's `FieldPolicy`. These functions are called whenever the associated field is queried (`read`) or updated (`merge`) in the cache. + +### The `read` function + +If you define a `read` function for a field, the cache calls that function whenever your client queries for the field. In the query response, the field is populated with the `read` function's return value, _instead of the field's cached value_. + +The `read` function takes the field's cached value as a parameter, so you can use it to help determine the function's return value. + +The following `read` function assigns a default value of `UNKNOWN NAME` to the `name` field of a `Person` type, if the actual value is not available in the cache. In all other cases, the cached value is returned. + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + name: { + read(name = "UNKNOWN NAME") { + return name; + } + }, + }, + }, + }, +}); +``` + +If a field accepts arguments, its associated `read` function is passed the values of those arguments. The following `read` function checks to see if the `maxLength` argument is provided when the `name` field is queried. If it is, the function returns only the first `maxLength` characters of the person's name. Otherwise, the person's full name is returned. + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + + // If a field's TypePolicy would only include a read function, + // you can optionally define the function like so, instead of + // nesting it inside an object as shown in the example above. + name(name: string, { args }) { + if (args && typeof args.maxLength === "number") { + return name.substring(0, args.maxLength); + } + return name; + }, + }, + }, + }, +}); +``` + +You can define a `read` function for a field that isn't even defined in your schema. For example, the following `read` function enables you to query a `userId` field that is always populated with locally stored data: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Person: { + fields: { + userId() { + return localStorage.loggedInUserId; + }, + }, + }, + }, +}); +``` + +> Note that to query for a field that is only defined locally, your query should [include the `@client` directive](/data/local-state/#querying-local-state) on that field so that Apollo Client doesn't include it in requests to your GraphQL server. + +Other use cases for a `read` function include: + +* Transforming cached data to suit your client's needs, such as rounding floating-point values to the nearest integer +* Deriving local-only fields from one or more schema fields on the same object (such as deriving an `age` field from a `birthDate` field) +* Deriving local-only fields from one or more schema fields across _multiple_ objects + +For a full list of the options provided to the `read` function, see the [API reference](#fieldpolicy-api-reference). You will almost never need to use all of these options, but each one has an important role when reading fields from the cache. + +### The `merge` function + +If you define a `merge` function for a field, the cache calls that function whenever the field is about to be written with an incoming value (such as from your GraphQL server). When the write occurs, the field's new value is set to the `merge` function's return value, _instead of the original incoming value_. + +#### Merging arrays + +A common use case for a `merge` function is to define how to write to a field that holds an array. By default, the field's existing array is _completely replaced_ by the incoming array. Often, it's preferable to _concatenate_ the two arrays instead, like so: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Agenda: { + fields: { + tasks: { + merge(existing = [], incoming: any[]) { + return [...existing, ...incoming]; + }, + }, + }, + }, + }, +}); +``` + +Note that `existing` is undefined the very first time this function is called for a given instance of the field, because the cache does not yet contain any data for the field. Providing the `existing = []` default parameter is a convenient way to handle this case. + +> Your `merge` function **cannot** push the `incoming` array directly onto the `existing` array. It must instead return a new array to prevent potential errors. In development mode, Apollo Client prevents unintended modification of the `existing` data with `Object.freeze`. + +#### Merging non-normalized objects + +Another common use case for `merge` functions is to combine nested objects that do not have IDs but definitely represent the same underlying object. Suppose that a `Book` type has an `author` field, which is an object containing information like the author's `name`, `primaryLanguage`, and `yearOfBirth`. + +TODO + +### Handling pagination + +When a field holds an array, it's often useful to [paginate](/data/pagination/) that array's results, because the total result set can be arbitrarily large. + +Typically, a query includes pagination arguments that specify: + +* Where to start in the array, using either a numeric offset or a starting ID +* The maximum number of elements to return in a single "page" + +If you implement pagination for a field, it's important to keep pagination arguments in mind if you then implement `read` and `merge` functions for the field: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Agenda: { + fields: { + tasks: { + merge(existing: any[], incoming: any[], { args }) { + const merged = existing ? existing.slice(0) : []; + // Insert the incoming elements in the right places, according to args. + for (let i = args.offset; i < args.offset + args.limit; ++i) { + merged[i] = incoming[i - args.offset]; + } + return merged; + }, + + read(existing: any[], { args }) { + // If we read the field before any data has been written to the + // cache, this function will return undefined, which correctly + // indicates that the field is missing. + return existing && existing.slice( + args.offset, + args.offset + args.limit, + ); + }, + }, + }, + }, + }, +}); +``` + +As this example shows, your `read` function often needs to cooperate with your `merge` function, by handling the same arguments in the inverse direction. + +If you want a given "page" to start after a specific entity ID instead of starting from `args.offset`, you can implement your `merge` and `read` functions as follows, using the `readField` helper function to examine existing task IDs: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Agenda: { + fields: { + tasks: { + merge(existing: any[], incoming: any[], { args, readField }) { + const merged = existing ? existing.slice(0) : []; + // Obtain a Set of all existing task IDs. + const existingIdSet = new Set( + merged.map(task => readField("id", task))); + // Remove incoming tasks already present in the existing data. + incoming = incoming.filter( + task => !existingIdSet.has(readField("id", task))); + // Find the index of the task just before the incoming page of tasks. + const afterIndex = merged.findIndex( + task => args.afterId === readField("id", task)); + if (afterIndex >= 0) { + // If we found afterIndex, insert incoming after that index. + merged.splice(afterIndex + 1, 0, ...incoming); + } else { + // Otherwise insert incoming at the end of the existing data. + merged.push(...incoming); + } + return merged; + }, + + read(existing: any[], { args, readField }) { + if (existing) { + const afterIndex = existing.findIndex( + task => args.afterId === readField("id", task)); + if (afterIndex >= 0) { + return existing.slice( + afterIndex + 1, + afterIndex + 1 + args.limit, + ); + } + } + }, + }, + }, + }, + }, +}); +``` + +Note that if you call `readField(fieldName)`, it returns the value of the specified field from the current object. If you pass an object as a _second_ argument to `readField`, (e.g., `readField("id", task)`), `readField` instead reads the specified field from the specified object. In the above example, reading the `id` field from existing `Task` objects allows us to deduplicate the `incoming` task data. + +The pagination code above is complicated, but after you define it for your preferred pagination strategy, you can reuse it for every field that uses that strategy, regardless of the field's type. For example: + +```ts +function afterIdLimitPaginatedFieldPolicy() { + return { + merge(existing: T[], incoming: T[], { args, readField }): T[] { + ... + }, + read(existing: T[], { args, readField }): T[] { + ... + }, + }; +} + +const cache = new InMemoryCache({ + typePolicies: { + Agenda: { + fields: { + tasks: afterIdLimitPaginatedFieldPolicy(), + }, + }, + }, +}); +``` + +## `FieldPolicy` API reference + +Here are the definitions for the `FieldPolicy` type and its related types: + +```ts +export type FieldPolicy = { + keyArgs?: KeySpecifier | KeyArgsFunction | false; + read?: FieldReadFunction; + merge?: FieldMergeFunction; +}; + +type KeyArgsFunction = ( + field: FieldNode, + context: { + typename: string; + variables: Record; + policies: Policies; + }, +) => string | null | void; + +// These options are common to both read and merge functions: +interface FieldFunctionOptions { + // The final argument values passed to the field, after applying variables. + // If no arguments were provided, this property will be null. + args: Record | null; + + // The name of the field, equal to options.field.name.value when + // options.field is available. Useful if you reuse the same function for + // multiple fields, and you need to know which field you're currently + // processing. Always a string, even when options.field is null. + fieldName: string; + + // The FieldNode object used to read this field. Useful if you need to + // know about other attributes of the field, such as its directives. This + // option will be null when a string was passed to options.readField. + field: FieldNode | null; + + // The variables that were provided when reading the query that contained + // this field. Possibly undefined, if no variables were provided. + variables?: Record; + + // Utilities for handling { __ref: string } references. + isReference(obj: any): obj is Reference; + toReference(obj: StoreObject): Reference; + + // A reference to the Policies object created by passing typePolicies to + // the InMemoryCache constructor, for advanced/internal use. + policies: Policies; +} + +// These options are specific to read functions: +interface ReadFunctionOptions extends FieldFunctionOptions { + // Helper function for reading other fields within the current object. + // If a foreign object or reference is provided, the field will be read + // from that object instead of the current object, so this function can + // be used (together with isReference) to examine the cache outside the + // current object. If a FieldNode is passed instead of a string, and + // that FieldNode has arguments, the same options.variables will be used + // to compute the argument values. Note that this function will invoke + // custom read functions for other fields, if defined. Always returns + // immutable data (enforced with Object.freeze in development). + readField( + nameOrField: string | FieldNode, + foreignObjOrRef?: StoreObject | Reference, + ): Readonly; + + // A handy place to put field-specific data that you want to survive + // across multiple read function calls. Useful for caching. + storage: Record; + + // Call this function to invalidate any cached queries that previously + // consumed this field. If you use options.storage as a cache, setting a + // new value in the cache and then calling options.invalidate() can be a + // good way to deliver asynchronous results. + invalidate(): void; +} + +type FieldReadFunction = ( + existing: Readonly | undefined, + options: ReadFunctionOptions, +) => TResult; + +type FieldMergeFunction = ( + existing: Readonly | undefined, + incoming: Readonly, + options: FieldFunctionOptions, +) => TExisting; +``` From 019c929968ef4a742fa9cf4fc378231df64af9a9 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 17 Jan 2020 18:22:18 -0500 Subject: [PATCH 06/10] Fix up FieldPolicy type and related types in documentation. --- docs/source/caching/cache-field-behavior.md | 54 +++++++++++---------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/source/caching/cache-field-behavior.md b/docs/source/caching/cache-field-behavior.md index 5a7ca2c842f..470b3a68426 100644 --- a/docs/source/caching/cache-field-behavior.md +++ b/docs/source/caching/cache-field-behavior.md @@ -285,10 +285,12 @@ const cache = new InMemoryCache({ Here are the definitions for the `FieldPolicy` type and its related types: ```ts -export type FieldPolicy = { +// These generic type parameters will be inferred from the provided policy in +// most cases, though you can use this type to constrain them more precisely. +export type FieldPolicy = { keyArgs?: KeySpecifier | KeyArgsFunction | false; - read?: FieldReadFunction; - merge?: FieldMergeFunction; + read?: FieldReadFunction; + merge?: FieldMergeFunction; }; type KeyArgsFunction = ( @@ -300,6 +302,17 @@ type KeyArgsFunction = ( }, ) => string | null | void; +type FieldReadFunction = ( + existing: Readonly | undefined, + options: FieldFunctionOptions, +) => TResult; + +type FieldMergeFunction = ( + existing: Readonly | undefined, + incoming: Readonly, + options: FieldFunctionOptions, +) => TExisting; + // These options are common to both read and merge functions: interface FieldFunctionOptions { // The final argument values passed to the field, after applying variables. @@ -325,13 +338,6 @@ interface FieldFunctionOptions { isReference(obj: any): obj is Reference; toReference(obj: StoreObject): Reference; - // A reference to the Policies object created by passing typePolicies to - // the InMemoryCache constructor, for advanced/internal use. - policies: Policies; -} - -// These options are specific to read functions: -interface ReadFunctionOptions extends FieldFunctionOptions { // Helper function for reading other fields within the current object. // If a foreign object or reference is provided, the field will be read // from that object instead of the current object, so this function can @@ -344,27 +350,23 @@ interface ReadFunctionOptions extends FieldFunctionOptions { readField( nameOrField: string | FieldNode, foreignObjOrRef?: StoreObject | Reference, - ): Readonly; + ): T; // A handy place to put field-specific data that you want to survive - // across multiple read function calls. Useful for caching. + // across multiple read function calls. Useful for field-level caching, + // if your read function does any expensive work. storage: Record; // Call this function to invalidate any cached queries that previously - // consumed this field. If you use options.storage as a cache, setting a - // new value in the cache and then calling options.invalidate() can be a - // good way to deliver asynchronous results. + // consumed this field. If you use options.storage to cache the result + // of an expensive read function, updating options.storage and then + // calling options.invalidate() can be a good way to deliver the new + // result asynchronously. invalidate(): void; -} - -type FieldReadFunction = ( - existing: Readonly | undefined, - options: ReadFunctionOptions, -) => TResult; -type FieldMergeFunction = ( - existing: Readonly | undefined, - incoming: Readonly, - options: FieldFunctionOptions, -) => TExisting; + // In rare advanced use cases, a read or merge function may wish to + // consult the current Policies object, for example to call + // getStoreFieldName manually. + policies: Policies; +} ``` From fb53c1640fff45f5875dabbc3cf29fb6b384dbc0 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 21 Jan 2020 10:32:13 -0500 Subject: [PATCH 07/10] Field{Policy,ReadFunction,MergeFunction} type documentation tweaks. --- docs/source/caching/cache-field-behavior.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/source/caching/cache-field-behavior.md b/docs/source/caching/cache-field-behavior.md index 470b3a68426..a2548fcd895 100644 --- a/docs/source/caching/cache-field-behavior.md +++ b/docs/source/caching/cache-field-behavior.md @@ -87,7 +87,7 @@ const cache = new InMemoryCache({ fields: { // If a field's TypePolicy would only include a read function, - // you can optionally define the function like so, instead of + // you can optionally define the function like so, instead of // nesting it inside an object as shown in the example above. name(name: string, { args }) { if (args && typeof args.maxLength === "number") { @@ -168,7 +168,7 @@ When a field holds an array, it's often useful to [paginate](/data/pagination/) Typically, a query includes pagination arguments that specify: * Where to start in the array, using either a numeric offset or a starting ID -* The maximum number of elements to return in a single "page" +* The maximum number of elements to return in a single "page" If you implement pagination for a field, it's important to keep pagination arguments in mind if you then implement `read` and `merge` functions for the field: @@ -287,12 +287,18 @@ Here are the definitions for the `FieldPolicy` type and its related types: ```ts // These generic type parameters will be inferred from the provided policy in // most cases, though you can use this type to constrain them more precisely. -export type FieldPolicy = { +type FieldPolicy< + TExisting, + TIncoming = TExisting, + TReadResult = TExisting, +> = { keyArgs?: KeySpecifier | KeyArgsFunction | false; read?: FieldReadFunction; merge?: FieldMergeFunction; }; +type KeySpecifier = (string | KeySpecifier)[]; + type KeyArgsFunction = ( field: FieldNode, context: { @@ -302,12 +308,12 @@ type KeyArgsFunction = ( }, ) => string | null | void; -type FieldReadFunction = ( +type FieldReadFunction = ( existing: Readonly | undefined, options: FieldFunctionOptions, -) => TResult; +) => TReadResult; -type FieldMergeFunction = ( +type FieldMergeFunction = ( existing: Readonly | undefined, incoming: Readonly, options: FieldFunctionOptions, From 22d463dfbbbddbe026400e2dd8c3ed25a17824b1 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 21 Jan 2020 11:36:31 -0500 Subject: [PATCH 08/10] Finish docs about merging non-normalized objects. The warnings mentioned in this section are TBD, because the warning message needs to be able to link to this documentation for further explanation. --- docs/source/caching/cache-field-behavior.md | 78 ++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/source/caching/cache-field-behavior.md b/docs/source/caching/cache-field-behavior.md index a2548fcd895..58f3f12b77b 100644 --- a/docs/source/caching/cache-field-behavior.md +++ b/docs/source/caching/cache-field-behavior.md @@ -157,9 +157,83 @@ Note that `existing` is undefined the very first time this function is called fo #### Merging non-normalized objects -Another common use case for `merge` functions is to combine nested objects that do not have IDs but definitely represent the same underlying object. Suppose that a `Book` type has an `author` field, which is an object containing information like the author's `name`, `primaryLanguage`, and `yearOfBirth`. +Another common use case for `merge` functions is to combine nested objects that do not have IDs, but are known (by you, the application developer) to represent the same logical object, assuming the parent object is the same. -TODO +Suppose that a `Book` type has an `author` field, which is an object containing information like the author's `name`, `language`, and `dateOfBirth`. The `Book` object has `__typename: "Book"` and a unique `isbn` field, so the cache can tell when two `Book` result objects represent the same logical entity. However, for whatever reason, the query that retrieved this `Book` did not ask for enough information about the `book.author` object. Perhaps no `keyFields` were specified for the `Author` type, and there is no default `id` field. + +This lack of identifying information poses a problem for the cache, because it cannot determine automatically whether two `Author` result objects are the same. If multiple queries ask for different information about the author of this `Book`, the order of the queries begins to matter, because the `favoriteBook.author` object from the second query cannot be safely merged with the `favoriteBook.author` object from the first query, and vice-versa: + +```gql +query BookWithAuthorName { + favoriteBook { + isbn + title + author { + name + } + } +} + +query BookWithAuthorLanguage { + favoriteBook { + isbn + title + author { + language + } + } +} +``` + +In such situations, the cache defaults to _replacing_ the existing `favoriteBook.author` data with the incoming data, without merging the `name` and `language` fields together, because the risk of merging inconsistent `name` and `language` fields from different authors is unacceptable. + +> Note: Apollo Client 2.x would sometimes merge unidentified objects together. While this behavior might accidentally have aligned with the intentions of the developer, it led to subtle inconsistencies within the cache. Apollo Client 3.0 refuses to perform unsafe merges, and instead warns about potential loss of unidentified data. + +You could fix this problem by modifying your queries to request an `id` field for the `favoriteBook.author` objects, or by specifying custom `keyFields` in the `Author` type policy, such as `["name", "dateOfBirth"]`. Providing the cache with this information allows it to know when two `Author` objects represent the same logical entity, so that it can safely merge their fields. This solution is recommended, when feasible. + +However, you may encounter situations where your data model does not provide any uniquely identifying fields for `Author` objects. In these rare scenarios, it might be safe to assume that a given `Book` has one and only one primary `Author`, and the author never changes. In other words, the identity of the author is implied by the identity of the book. This common-sense knowledge is something you have at your disposal, as a human, but it must be communicated to the cache, which is neither human nor capable of telepathy. + +In such situations, you can define a custom `merge` function for the `author` field within the type policy for `Book`: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Book: { + fields: { + author: { + merge(existing, incoming) { + return { ...existing, ...incoming }; + }, + }, + }, + }, + }, +}); +``` + +This policy allows the cache to safely merge the `author` objects of any two `Book` objects that have the same identity. + +If you would prefer to keep the default replacement behavior while silencing the warnings, the following `merge` function will explicitly permit replacement: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Book: { + fields: { + author: { + merge(_ignored, incoming) { + return incoming; + }, + }, + }, + }, + }, +}); +``` + +Of course, you can implement your `merge` functions however you like—these are just the simplest and most common implementations. + +If you do end up specifying `keyFields` for the `Author` type, the `existing` and `incoming` parameters to the `merge` function will be `Reference` objects with the shape `{ __ref: }`, referring to normalized data elsewhere in the cache. A custom `merge` function will no longer be necessary, but remains safe because it prefers `incoming` references. ### Handling pagination From bf2dcc0a7600467fea15a59cd1652692b8df4d25 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 21 Jan 2020 12:16:07 -0500 Subject: [PATCH 09/10] Documentation for custom merge functions for array-valued fields. --- docs/source/caching/cache-field-behavior.md | 86 ++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/source/caching/cache-field-behavior.md b/docs/source/caching/cache-field-behavior.md index 58f3f12b77b..a68e554de98 100644 --- a/docs/source/caching/cache-field-behavior.md +++ b/docs/source/caching/cache-field-behavior.md @@ -233,7 +233,89 @@ const cache = new InMemoryCache({ Of course, you can implement your `merge` functions however you like—these are just the simplest and most common implementations. -If you do end up specifying `keyFields` for the `Author` type, the `existing` and `incoming` parameters to the `merge` function will be `Reference` objects with the shape `{ __ref: }`, referring to normalized data elsewhere in the cache. A custom `merge` function will no longer be necessary, but remains safe because it prefers `incoming` references. +#### Merging arrays of non-normalized objects + +Once you're comfortable with the ideas and recommendations from the previous section, consider what happens when a `Book` can have multiple authors: + +```gql +query BookWithAuthorNames { + favoriteBook { + isbn + title + authors { + name + } + } +} + +query BookWithAuthorLanguages { + favoriteBook { + isbn + title + authors { + language + } + } +} +``` + +In this case, the `favoriteBook.authors` field is no longer just a single object, but an array of authors, so it's even more imporant to define a custom `merge` function to prevent loss of data by replacement: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Book: { + fields: { + authors: { + merge(existing: any[] = [], incoming: any[], { readField }) { + const merged: any[] = []; + const authors = new Map(); + function add(author: any) { + const name = readField("name", author); + if (authors.has(name)) { + // Merge the new author data with the existing author data. + authors.set(name, { + ...authors.get(name), + ...author, + }); + } else { + // First time we've seen this author in this array. + authors.set(name, author); + merged.push(author); + } + } + existing.forEach(add); + incoming.forEach(add); + return merged; + }, + }, + }, + }, + }, +}); +``` + +Instead of blindly replacing the existing `authors` array with the incoming array, this code concatenates the arrays together, while also checking for duplicate author names, merging the fields of any repeated `author` objects. + +The `readField` helper function is more robust than using `author.name`, because it also tolerates the possibility that the `author` is a `Reference` object referring to data elsewhere in the cache, which could happen if you (or someone else on your team) eventually gets around to specifying `keyFields` for the `Author` type. + +As this example suggests, `merge` functions can become quite sophisticated. When this happens, you can often extract the generic logic into a reusable helper function: + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Book: { + fields: { + authors: { + merge: mergeArrayByField("name"), + }, + }, + }, + }, +}); +``` + +Now that you've hidden the details behind a reusable abstraction, it no longer matters how complicated the implementation gets. This is liberating, because it allows you to improve your client-side business logic over time, while keeping related logic consistent across your entire application. ### Handling pagination @@ -329,7 +411,7 @@ const cache = new InMemoryCache({ Note that if you call `readField(fieldName)`, it returns the value of the specified field from the current object. If you pass an object as a _second_ argument to `readField`, (e.g., `readField("id", task)`), `readField` instead reads the specified field from the specified object. In the above example, reading the `id` field from existing `Task` objects allows us to deduplicate the `incoming` task data. -The pagination code above is complicated, but after you define it for your preferred pagination strategy, you can reuse it for every field that uses that strategy, regardless of the field's type. For example: +The pagination code above is complicated, but after you implement your preferred pagination strategy, you can reuse it for every field that uses that strategy, regardless of the field's type. For example: ```ts function afterIdLimitPaginatedFieldPolicy() { From 48fc3ba5083cc6a421789412ccb358e9dde2030a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 21 Jan 2020 12:42:45 -0500 Subject: [PATCH 10/10] Cache documentation tweaks after reviewing Netlify preview. --- docs/source/caching/cache-field-behavior.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/source/caching/cache-field-behavior.md b/docs/source/caching/cache-field-behavior.md index a68e554de98..7d8672dd78b 100644 --- a/docs/source/caching/cache-field-behavior.md +++ b/docs/source/caching/cache-field-behavior.md @@ -85,7 +85,6 @@ const cache = new InMemoryCache({ typePolicies: { Person: { fields: { - // If a field's TypePolicy would only include a read function, // you can optionally define the function like so, instead of // nesting it inside an object as shown in the example above. @@ -157,13 +156,13 @@ Note that `existing` is undefined the very first time this function is called fo #### Merging non-normalized objects -Another common use case for `merge` functions is to combine nested objects that do not have IDs, but are known (by you, the application developer) to represent the same logical object, assuming the parent object is the same. +Another common use case for custom field `merge` functions is to combine nested objects that do not have IDs, but are known (by you, the application developer) to represent the same logical object, assuming the parent object is the same. Suppose that a `Book` type has an `author` field, which is an object containing information like the author's `name`, `language`, and `dateOfBirth`. The `Book` object has `__typename: "Book"` and a unique `isbn` field, so the cache can tell when two `Book` result objects represent the same logical entity. However, for whatever reason, the query that retrieved this `Book` did not ask for enough information about the `book.author` object. Perhaps no `keyFields` were specified for the `Author` type, and there is no default `id` field. -This lack of identifying information poses a problem for the cache, because it cannot determine automatically whether two `Author` result objects are the same. If multiple queries ask for different information about the author of this `Book`, the order of the queries begins to matter, because the `favoriteBook.author` object from the second query cannot be safely merged with the `favoriteBook.author` object from the first query, and vice-versa: +This lack of identifying information poses a problem for the cache, because it cannot determine automatically whether two `Author` result objects are the same. If multiple queries ask for different information about the author of this `Book`, the order of the queries matters, because the `favoriteBook.author` object from the second query cannot be safely merged with the `favoriteBook.author` object from the first query, and vice-versa: -```gql +```graphql query BookWithAuthorName { favoriteBook { isbn @@ -187,11 +186,11 @@ query BookWithAuthorLanguage { In such situations, the cache defaults to _replacing_ the existing `favoriteBook.author` data with the incoming data, without merging the `name` and `language` fields together, because the risk of merging inconsistent `name` and `language` fields from different authors is unacceptable. -> Note: Apollo Client 2.x would sometimes merge unidentified objects together. While this behavior might accidentally have aligned with the intentions of the developer, it led to subtle inconsistencies within the cache. Apollo Client 3.0 refuses to perform unsafe merges, and instead warns about potential loss of unidentified data. +> Note: Apollo Client 2.x would sometimes merge unidentified objects. While this behavior might accidentally have aligned with the intentions of the developer, it led to subtle inconsistencies within the cache. Apollo Client 3.0 refuses to perform unsafe merges, and instead warns about potential loss of unidentified data. -You could fix this problem by modifying your queries to request an `id` field for the `favoriteBook.author` objects, or by specifying custom `keyFields` in the `Author` type policy, such as `["name", "dateOfBirth"]`. Providing the cache with this information allows it to know when two `Author` objects represent the same logical entity, so that it can safely merge their fields. This solution is recommended, when feasible. +You could fix this problem by modifying your queries to request an `id` field for the `favoriteBook.author` objects, or by specifying custom `keyFields` in the `Author` type policy, such as `["name", "dateOfBirth"]`. Providing the cache with this information allows it to know when two `Author` objects represent the same logical entity, so it can safely merge their fields. This solution is recommended, when feasible. -However, you may encounter situations where your data model does not provide any uniquely identifying fields for `Author` objects. In these rare scenarios, it might be safe to assume that a given `Book` has one and only one primary `Author`, and the author never changes. In other words, the identity of the author is implied by the identity of the book. This common-sense knowledge is something you have at your disposal, as a human, but it must be communicated to the cache, which is neither human nor capable of telepathy. +However, you may encounter situations where your data graph does not provide any uniquely identifying fields for `Author` objects. In these rare scenarios, it might be safe to assume that a given `Book` has one and only one primary `Author`, and the author never changes. In other words, the identity of the author is implied by the identity of the book. This common-sense knowledge is something you have at your disposal, as a human, but it must be communicated to the cache, which is neither human nor capable of telepathy. In such situations, you can define a custom `merge` function for the `author` field within the type policy for `Book`: @@ -237,7 +236,7 @@ Of course, you can implement your `merge` functions however you like—these Once you're comfortable with the ideas and recommendations from the previous section, consider what happens when a `Book` can have multiple authors: -```gql +```graphql query BookWithAuthorNames { favoriteBook { isbn