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);