diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index bfb6512a442..ecd32687ac1 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -18,9 +18,13 @@ export abstract class ApolloCache implements DataProxy { ): void; public abstract diff(query: Cache.DiffOptions): Cache.DiffResult; public abstract watch(watch: Cache.WatchOptions): () => void; - public abstract evict(dataId: string): boolean; public abstract reset(): Promise; + // If called with only one argument, removes the entire entity + // identified by dataId. If called with a fieldName as well, removes all + // fields of the identified entity whose store names match fieldName. + public abstract evict(dataId: string, fieldName?: string): boolean; + // intializer / offline / ssr API /** * Replaces existing state in the cache (if any) with the values expressed by diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 7821a4178a8..63a943bfc7b 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -763,6 +763,305 @@ describe('EntityStore', () => { ]); }); + it("allows evicting specific fields", () => { + const query: DocumentNode = gql` + query { + authorOfBook(isbn: $isbn) { + name + hobby + } + publisherOfBook(isbn: $isbn) { + name + yearOfFounding + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + authorOfBook: { + keyArgs: ["isbn"], + }, + }, + }, + Author: { + keyFields: ["name"], + }, + Publisher: { + keyFields: ["name"], + }, + }, + }); + + const TedChiangData = { + __typename: "Author", + name: "Ted Chiang", + hobby: "video games", + }; + + const KnopfData = { + __typename: "Publisher", + name: "Alfred A. Knopf", + yearOfFounding: 1915, + }; + + cache.writeQuery({ + query, + data: { + authorOfBook: TedChiangData, + publisherOfBook: KnopfData, + }, + variables: { + isbn: "1529014514", + }, + }); + + const justTedRootQueryData = { + __typename: "Query", + 'authorOfBook:{"isbn":"1529014514"}': { + __ref: 'Author:{"name":"Ted Chiang"}', + }, + // This storeFieldName format differs slightly from that of + // authorOfBook because we did not define keyArgs for the + // publisherOfBook field, so the legacy storeKeyNameFromField + // function was used instead. + 'publisherOfBook({"isbn":"1529014514"})': { + __ref: 'Publisher:{"name":"Alfred A. Knopf"}', + }, + }; + + expect(cache.extract()).toEqual({ + ROOT_QUERY: justTedRootQueryData, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': KnopfData, + }); + + const JennyOdellData = { + __typename: "Author", + name: "Jenny Odell", + hobby: "birding", + }; + + const MelvilleData = { + __typename: "Publisher", + name: "Melville House", + yearOfFounding: 2001, + }; + + cache.writeQuery({ + query, + data: { + authorOfBook: JennyOdellData, + publisherOfBook: MelvilleData, + }, + variables: { + isbn: "1760641790", + }, + }); + + const justJennyRootQueryData = { + __typename: "Query", + 'authorOfBook:{"isbn":"1760641790"}': { + __ref: 'Author:{"name":"Jenny Odell"}', + }, + 'publisherOfBook({"isbn":"1760641790"})': { + __ref: 'Publisher:{"name":"Melville House"}', + }, + }; + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...justTedRootQueryData, + ...justJennyRootQueryData, + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': KnopfData, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + 'Publisher:{"name":"Melville House"}': MelvilleData, + }); + + const fullTedResult = cache.readQuery({ + query, + variables: { + isbn: "1529014514", + }, + }); + + expect(fullTedResult).toEqual({ + authorOfBook: TedChiangData, + publisherOfBook: KnopfData, + }); + + const fullJennyResult = cache.readQuery({ + query, + variables: { + isbn: "1760641790", + }, + }); + + expect(fullJennyResult).toEqual({ + authorOfBook: JennyOdellData, + publisherOfBook: MelvilleData, + }); + + cache.evict( + cache.identify({ + __typename: "Publisher", + name: "Alfred A. Knopf", + }), + "yearOfFounding", + ); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...justTedRootQueryData, + ...justJennyRootQueryData, + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': { + __typename: "Publisher", + name: "Alfred A. Knopf", + // yearOfFounding has been removed + }, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + 'Publisher:{"name":"Melville House"}': MelvilleData, + }); + + // Nothing to garbage collect yet. + expect(cache.gc()).toEqual([]); + + cache.evict(cache.identify({ + __typename: "Publisher", + name: "Melville House", + })); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...justTedRootQueryData, + ...justJennyRootQueryData, + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': { + __typename: "Publisher", + name: "Alfred A. Knopf", + }, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + // Melville House has been removed + }); + + cache.evict("ROOT_QUERY", "publisherOfBook"); + + function withoutPublisherOfBook(obj: object) { + const clean = { ...obj }; + Object.keys(obj).forEach(key => { + if (key.startsWith("publisherOfBook")) { + delete clean[key]; + } + }); + return clean; + } + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...withoutPublisherOfBook(justTedRootQueryData), + ...withoutPublisherOfBook(justJennyRootQueryData), + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': { + __typename: "Publisher", + name: "Alfred A. Knopf", + }, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + }); + + expect(cache.gc()).toEqual([ + 'Publisher:{"name":"Alfred A. Knopf"}', + ]); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...withoutPublisherOfBook(justTedRootQueryData), + ...withoutPublisherOfBook(justJennyRootQueryData), + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + }); + + const partialTedResult = cache.diff({ + query, + returnPartialData: true, + optimistic: false, // required but not important + variables: { + isbn: "1529014514", + }, + }); + expect(partialTedResult.complete).toBe(false); + expect(partialTedResult.result).toEqual({ + authorOfBook: TedChiangData, + }); + // The result caching system preserves the referential identity of + // unchanged nested result objects. + expect( + partialTedResult.result.authorOfBook, + ).toBe(fullTedResult.authorOfBook); + + const partialJennyResult = cache.diff({ + query, + returnPartialData: true, + optimistic: true, // required but not important + variables: { + isbn: "1760641790", + }, + }); + expect(partialJennyResult.complete).toBe(false); + expect(partialJennyResult.result).toEqual({ + authorOfBook: JennyOdellData, + }); + // The result caching system preserves the referential identity of + // unchanged nested result objects. + expect( + partialJennyResult.result.authorOfBook, + ).toBe(fullJennyResult.authorOfBook); + + const tedWithoutHobby = { + __typename: "Author", + name: "Ted Chiang", + }; + + cache.evict( + cache.identify(tedWithoutHobby), + "hobby", + ); + + expect(cache.diff({ + query, + returnPartialData: true, + optimistic: false, // required but not important + variables: { + isbn: "1529014514", + }, + })).toEqual({ + complete: false, + result: { + authorOfBook: tedWithoutHobby, + }, + }); + + cache.evict("ROOT_QUERY", "authorOfBook"); + expect(cache.gc().sort()).toEqual([ + 'Author:{"name":"Jenny Odell"}', + 'Author:{"name":"Ted Chiang"}', + ]); + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + // Everything else has been removed. + __typename: "Query", + }, + }); + }); + it("supports cache.identify(object)", () => { const queryWithAliases: DocumentNode = gql` query { diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index e680fdded98..a66d1f6ff5b 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -102,23 +102,83 @@ export abstract class EntityStore implements NormalizedCache { } } - // TODO Allow deleting fields of this.data[dataId] according to their - // original field.name.value. - public delete(dataId: string): void { - const storeObject = this.data[dataId]; - - delete this.data[dataId]; - delete this.refs[dataId]; - // Note that we do not delete the this.rootIds[dataId] retainment - // count for this ID, since an object with the same ID could appear in - // the store again, and should not have to be retained again. - // delete this.rootIds[dataId]; - - if (this.depend && storeObject) { - dirty(this, dataId); - Object.keys(storeObject).forEach(fieldName => { - dirty(this, dataId, fieldName); + // If called with only one argument, removes the entire entity + // identified by dataId. If called with a fieldName as well, removes all + // fields of that entity whose names match fieldName, according to the + // fieldNameFromStoreName helper function. + public delete(dataId: string, fieldName?: string) { + const storeObject = this.get(dataId); + + if (storeObject) { + // In case someone passes in a storeFieldName (field.name.value + + // arguments key), normalize it down to just the field name. + fieldName = fieldName && fieldNameFromStoreName(fieldName); + + const storeNamesToDelete: string[] = []; + Object.keys(storeObject).forEach(storeFieldName => { + // If the field value has already been set to undefined, we do not + // need to delete it again. + if (storeObject[storeFieldName] !== void 0 && + // If no fieldName provided, delete all fields from storeObject. + // If provided, delete all fields matching fieldName. + (!fieldName || fieldName === fieldNameFromStoreName(storeFieldName))) { + storeNamesToDelete.push(storeFieldName); + } }); + + if (storeNamesToDelete.length) { + // If we only have to worry about the Root layer of the store, + // then we can safely delete fields within entities, or whole + // entities by ID. If this instanceof EntityStore.Layer, however, + // then we need to set the "deleted" values to undefined instead + // of actually deleting them, so the deletion does not un-shadow + // values inherited from lower layers of the store. + const canDelete = this instanceof EntityStore.Root; + const remove = (obj: Record, key: string) => { + if (canDelete) { + delete obj[key]; + } else { + obj[key] = void 0; + } + }; + + // Note that we do not delete the this.rootIds[dataId] retainment + // count for this ID, since an object with the same ID could appear in + // the store again, and should not have to be retained again. + // delete this.rootIds[dataId]; + delete this.refs[dataId]; + + const fieldsToDirty: Record = Object.create(null); + + if (fieldName) { + // If we have a fieldName and it matches more than zero fields, + // then we need to make a copy of this.data[dataId] without the + // fields that are getting deleted. + const cleaned = this.data[dataId] = { ...storeObject }; + storeNamesToDelete.forEach(storeFieldName => { + remove(cleaned, storeFieldName); + }); + // Although it would be logically correct to dirty each + // storeFieldName in the loop above, we know that they all have + // the same name, according to fieldNameFromStoreName. + fieldsToDirty[fieldName] = true; + } else { + // If no fieldName was provided, then we delete the whole entity + // from the cache. + remove(this.data, dataId); + storeNamesToDelete.forEach(storeFieldName => { + const fieldName = fieldNameFromStoreName(storeFieldName); + fieldsToDirty[fieldName] = true; + }); + } + + if (this.depend) { + dirty(this, dataId); + Object.keys(fieldsToDirty).forEach(fieldName => { + dirty(this, dataId, fieldName); + }); + } + } } } @@ -187,7 +247,7 @@ export abstract class EntityStore implements NormalizedCache { if (idsToRemove.length) { let root: EntityStore = this; while (root instanceof Layer) root = root.parent; - idsToRemove.forEach(root.delete, root); + idsToRemove.forEach(id => root.delete(id)); } return idsToRemove; } diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index dc8bdbd7c1c..6aa5c6263d8 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -206,12 +206,12 @@ export class InMemoryCache extends ApolloCache { return this.policies.identify(object); } - public evict(dataId: string): boolean { + public evict(dataId: string, fieldName?: string): boolean { if (this.optimisticData.has(dataId)) { // Note that this deletion does not trigger a garbage collection, which // is convenient in cases where you want to evict multiple entities before // performing a single garbage collection. - this.optimisticData.delete(dataId); + this.optimisticData.delete(dataId, fieldName); this.broadcastWatches(); return !this.optimisticData.has(dataId); } diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts index 329ba077658..9d0255dbdde 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -22,7 +22,7 @@ export interface NormalizedCache { get(dataId: string): StoreObject; getFieldValue(dataId: string, storeFieldName: string): StoreValue; merge(dataId: string, incoming: StoreObject): void; - delete(dataId: string): void; + delete(dataId: string, fieldName?: string): void; clear(): void; // non-Map elements: