Skip to content

Commit

Permalink
Allow getFieldValue to read from foreign Reference objects. (#5651)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
benjamn authored Dec 4, 2019
1 parent 03e5114 commit 5314cba
Show file tree
Hide file tree
Showing 3 changed files with 258 additions and 6 deletions.
237 changes: 237 additions & 0 deletions src/cache/inmemory/__tests__/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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<Reference[]>("books") || [];
bookRefs.forEach(bookRef => {
expect(isReference(bookRef)).toBe(true);
const year = getFieldValue<number>("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,
);
});
});
21 changes: 16 additions & 5 deletions src/cache/inmemory/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
argumentsObjectFromField,
makeReference,
isReference,
Reference,
} from '../../utilities/graphql/storeUtils';

import { canUseWeakMap } from '../../utilities/common/canUse';
Expand Down Expand Up @@ -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<StoreValue>;
// 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<T = StoreValue>(
field: string | FieldNode,
foreignRef?: Reference,
): Readonly<T>;
}

interface FieldReadFunction<TExisting, TResult = TExisting> {
Expand Down Expand Up @@ -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<string, any>,
): StoreValue {
Expand All @@ -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,
);
},
});
Expand Down Expand Up @@ -468,7 +479,7 @@ export class Policies {
}
}

function emptyGetFieldValueForMerge() {
function emptyGetFieldValueForMerge(): any {
invariant.warn("getFieldValue unavailable in merge functions");
}

Expand Down
6 changes: 5 additions & 1 deletion src/cache/inmemory/readFromStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit 5314cba

Please sign in to comment.