From 5314cba8fc003c4bcf356ad974b3d4243324987f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Dec 2019 09:14:46 -0500 Subject: [PATCH] Allow getFieldValue to read from foreign Reference objects. (#5651) The getFieldValue(fieldName) helper function was introduced in #5617 for reading fields from the current StoreObject during read functions. This commit adds a second parameter to getFieldValue, foreignRef, which is an optional Reference. When foreignRef is provided, getFieldValue will read the specified field from the StoreObject identified by the foreignRef, instead of reading from the current StoreObject. In either case, getFieldValue reads an existing value from the cache, without invoking any read functions, so you cannot use getFieldValue to set up expensive (and potentially cyclic) chains of read functions. With this new ability to read fields from arbitrary Reference objects, read functions can explore the entire reachable cache, without having to call cache.readQuery. The beauty of this system is that every field read operation requires a function call (getFieldValue), which allows the result caching system to know which fields were read from which entities, so future changes to those fields can properly invalidate any cached results that involved the original read function. --- src/cache/inmemory/__tests__/policies.ts | 237 +++++++++++++++++++++++ src/cache/inmemory/policies.ts | 21 +- src/cache/inmemory/readFromStore.ts | 6 +- 3 files changed, 258 insertions(+), 6 deletions(-) diff --git a/src/cache/inmemory/__tests__/policies.ts b/src/cache/inmemory/__tests__/policies.ts index b8db5b01ceb..ccffa300867 100644 --- a/src/cache/inmemory/__tests__/policies.ts +++ b/src/cache/inmemory/__tests__/policies.ts @@ -2,6 +2,7 @@ import gql from "graphql-tag"; import { InMemoryCache } from "../inMemoryCache"; import { StoreValue } from "../../../utilities"; import { FieldPolicy } from "../policies"; +import { Reference } from "../../../utilities/graphql/storeUtils"; describe("type policies", function () { const bookQuery = gql` @@ -1051,4 +1052,240 @@ describe("type policies", function () { }], }); }); + + it("can read from foreign references using getFieldValue", function () { + const cache = new InMemoryCache({ + typePolicies: { + Author: { + keyFields: ["name"], + + fields: { + books: { + merge(existing: Reference[] = [], incoming: Reference[]) { + return [...existing, ...incoming]; + }, + }, + + // A dynamically computed field that returns the Book + // Reference with the earliest year, which requires reading + // fields from foreign references. + firstBook(_, { isReference, getFieldValue }) { + let firstBook: Reference; + let firstYear: number; + const bookRefs = getFieldValue("books") || []; + bookRefs.forEach(bookRef => { + expect(isReference(bookRef)).toBe(true); + const year = getFieldValue("year", bookRef); + if (firstYear === void 0 || year < firstYear) { + firstBook = bookRef; + firstYear = year; + } + }); + // Return a Book Reference, which can have a nested + // selection set applied to it. + return firstBook; + }, + }, + }, + + Book: { + keyFields: ["isbn"], + }, + }, + }); + + function addBook(bookData) { + cache.writeQuery({ + query: gql` + query { + author { + name + books { + isbn + title + year + } + } + } + `, + data: { + author: { + __typename: "Author", + name: "Virginia Woolf", + books: [{ + __typename: "Book", + ...bookData, + }], + }, + }, + }); + } + + addBook({ + __typename: "Book", + isbn: "1853262390", + title: "Orlando", + year: 1928, + }); + + addBook({ + __typename: "Book", + isbn: "9353420717", + title: "A Room of One's Own", + year: 1929, + }); + + addBook({ + __typename: "Book", + isbn: "0156907399", + title: "To the Lighthouse", + year: 1927, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + author: { + __ref: 'Author:{"name":"Virginia Woolf"}', + }, + }, + 'Author:{"name":"Virginia Woolf"}': { + __typename: "Author", + name: "Virginia Woolf", + books: [ + { __ref: 'Book:{"isbn":"1853262390"}' }, + { __ref: 'Book:{"isbn":"9353420717"}' }, + { __ref: 'Book:{"isbn":"0156907399"}' }, + ], + }, + 'Book:{"isbn":"1853262390"}': { + __typename: "Book", + isbn: "1853262390", + title: "Orlando", + year: 1928, + }, + 'Book:{"isbn":"9353420717"}': { + __typename: "Book", + isbn: "9353420717", + title: "A Room of One's Own", + year: 1929, + }, + 'Book:{"isbn":"0156907399"}': { + __typename: "Book", + isbn: "0156907399", + title: "To the Lighthouse", + year: 1927, + }, + }); + + const firstBookQuery = gql` + query { + author { + name + firstBook { + title + year + } + } + } + `; + + function readFirstBookResult() { + return cache.readQuery<{ author: any }>({ + query: firstBookQuery, + }); + } + + const firstBookResult = readFirstBookResult(); + expect(firstBookResult).toEqual({ + author: { + __typename: "Author", + name: "Virginia Woolf", + firstBook: { + __typename: "Book", + title: "To the Lighthouse", + year: 1927, + }, + }, + }); + + expect(readFirstBookResult()).toBe(firstBookResult); + + // Add an even earlier book. + addBook({ + isbn: "1420959719", + title: "The Voyage Out", + year: 1915, + }); + + const secondFirstBookResult = readFirstBookResult(); + expect(secondFirstBookResult).not.toBe(firstBookResult); + expect(secondFirstBookResult).toEqual({ + author: { + __typename: "Author", + name: "Virginia Woolf", + firstBook: { + __typename: "Book", + title: "The Voyage Out", + year: 1915, + }, + }, + }); + + // Write a new, unrelated field. + cache.writeQuery({ + query: gql`query { author { afraidCount } }`, + data: { + author: { + __typename: "Author", + name: "Virginia Woolf", + afraidCount: 2, + }, + }, + }); + + // Make sure afraidCount was written. + expect(cache.readFragment({ + id: cache.identify({ + __typename: "Author", + name: "Virginia Woolf", + }), + fragment: gql` + fragment AfraidFragment on Author { + name + afraidCount + } + `, + })).toEqual({ + __typename: "Author", + name: "Virginia Woolf", + afraidCount: 2, + }); + + // Since we wrote only the afraidCount field, the firstBook result + // should be completely unchanged. + expect(readFirstBookResult()).toBe(secondFirstBookResult); + + // Add another book, not published first. + addBook({ + isbn: "9780156949606", + title: "The Waves", + year: 1931, + }); + + const thirdFirstBookResult = readFirstBookResult(); + + // A change in VW's books field triggers rereading of result objects + // that previously involved her books field. + expect(thirdFirstBookResult).not.toBe(secondFirstBookResult); + + // However, since the new Book was not the earliest published, the + // second and third results are structurally the same. + expect(thirdFirstBookResult).toEqual(secondFirstBookResult); + + // In fact, the original author.firstBook object has been reused! + expect(thirdFirstBookResult.author.firstBook).toBe( + secondFirstBookResult.author.firstBook, + ); + }); }); diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 7a1a09a92b4..7430448e91a 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -22,6 +22,7 @@ import { argumentsObjectFromField, makeReference, isReference, + Reference, } from '../../utilities/graphql/storeUtils'; import { canUseWeakMap } from '../../utilities/common/canUse'; @@ -99,8 +100,14 @@ interface FieldFunctionOptions { // Gets the existing StoreValue for a given field within the current // object, without calling any read functions, so it works even with the // current field. If the provided FieldNode has arguments, the same - // options.variables will be used. - getFieldValue(field: string | FieldNode): Readonly; + // options.variables will be used. If a foreignRef is provided, the + // value 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 entity. + getFieldValue( + field: string | FieldNode, + foreignRef?: Reference, + ): Readonly; } interface FieldReadFunction { @@ -418,7 +425,10 @@ export class Policies { public readFieldFromStoreObject( field: FieldNode, - getFieldValue: (field: string) => StoreValue, + getFieldValue: ( + field: string, + foreignRef?: Reference, + ) => any, typename = getFieldValue("__typename") as string, variables?: Record, ): StoreValue { @@ -433,10 +443,11 @@ export class Policies { variables, isReference, toReference: policies.toReference, - getFieldValue(nameOrField) { + getFieldValue(nameOrField, foreignRef) { return getFieldValue( typeof nameOrField === "string" ? nameOrField : policies.getStoreFieldName(typename, nameOrField, variables), + foreignRef, ); }, }); @@ -468,7 +479,7 @@ export class Policies { } } -function emptyGetFieldValueForMerge() { +function emptyGetFieldValueForMerge(): any { invariant.warn("getFieldValue unavailable in merge functions"); } diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index bfca9b7daa7..28c1565b214 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -206,8 +206,12 @@ export class StoreReader { // Provides a uniform interface from reading field values, whether or // not the parent object is a normalized entity object. - function getFieldValue(fieldName: string): StoreValue { + function getFieldValue( + fieldName: string, + foreignRef?: Reference, + ): StoreValue { let fieldValue: StoreValue; + if (foreignRef) objectOrReference = foreignRef; if (isReference(objectOrReference)) { const dataId = objectOrReference.__ref; fieldValue = store.getFieldValue(dataId, fieldName);