Skip to content

Commit fba874e

Browse files
committed
Implement InMemoryCache#evict method.
Eviction always succeeds if the given options.rootId is contained by the cache, but it does not automatically trigger garbage collection, since the developer might want to perform serveral evictions before triggering a single garbage collection. Resolves apollographql/apollo-feature-requests#4. Supersedes #4681.
1 parent 666e515 commit fba874e

File tree

4 files changed

+212
-5
lines changed

4 files changed

+212
-5
lines changed

src/cache/inmemory/__tests__/entityCache.ts

+180
Original file line numberDiff line numberDiff line change
@@ -569,4 +569,184 @@ describe('EntityCache', () => {
569569

570570
expect(cache.gc()).toEqual([]);
571571
});
572+
573+
it('allows cache eviction', () => {
574+
const { cache, query } = newBookAuthorCache();
575+
576+
cache.writeQuery({
577+
query,
578+
data: {
579+
book: {
580+
__typename: "Book",
581+
isbn: "031648637X",
582+
title: "The Cuckoo's Calling",
583+
author: {
584+
__typename: "Author",
585+
name: "Robert Galbraith",
586+
},
587+
},
588+
},
589+
});
590+
591+
expect(cache.evict({
592+
rootId: "Author:J.K. Rowling",
593+
query,
594+
})).toEqual({
595+
success: false,
596+
});
597+
598+
const bookAuthorFragment = gql`
599+
fragment BookAuthor on Book {
600+
author {
601+
name
602+
}
603+
}
604+
`;
605+
606+
const fragmentResult = cache.readFragment({
607+
id: "Book:031648637X",
608+
fragment: bookAuthorFragment,
609+
});
610+
611+
expect(fragmentResult).toEqual({
612+
__typename: "Book",
613+
author: {
614+
__typename: "Author",
615+
name: "Robert Galbraith",
616+
},
617+
});
618+
619+
cache.recordOptimisticTransaction(proxy => {
620+
proxy.writeFragment({
621+
id: "Book:031648637X",
622+
fragment: bookAuthorFragment,
623+
data: {
624+
...fragmentResult,
625+
author: {
626+
__typename: "Author",
627+
name: "J.K. Rowling",
628+
},
629+
},
630+
});
631+
}, "real name");
632+
633+
const snapshotWithBothNames = {
634+
ROOT_QUERY: {
635+
book: {
636+
__ref: "Book:031648637X",
637+
},
638+
},
639+
"Book:031648637X": {
640+
__typename: "Book",
641+
author: {
642+
__ref: "Author:J.K. Rowling",
643+
},
644+
title: "The Cuckoo's Calling",
645+
},
646+
"Author:Robert Galbraith": {
647+
__typename: "Author",
648+
name: "Robert Galbraith",
649+
},
650+
"Author:J.K. Rowling": {
651+
__typename: "Author",
652+
name: "J.K. Rowling",
653+
},
654+
};
655+
656+
expect(cache.extract(true)).toEqual(snapshotWithBothNames);
657+
658+
expect(cache.gc()).toEqual([]);
659+
660+
expect(cache.retain('Author:Robert Galbraith')).toBe(1);
661+
662+
expect(cache.gc()).toEqual([]);
663+
664+
expect(cache.evict({
665+
rootId: 'Author:Robert Galbraith',
666+
query,
667+
})).toEqual({
668+
success: true,
669+
});
670+
671+
expect(cache.gc()).toEqual([]);
672+
673+
cache.removeOptimistic("real name");
674+
675+
expect(cache.extract(true)).toEqual({
676+
ROOT_QUERY: {
677+
book: {
678+
__ref: "Book:031648637X",
679+
},
680+
},
681+
"Book:031648637X": {
682+
__typename: "Book",
683+
author: {
684+
__ref: "Author:Robert Galbraith",
685+
},
686+
title: "The Cuckoo's Calling",
687+
},
688+
"Author:Robert Galbraith": {
689+
__typename: "Author",
690+
name: "Robert Galbraith",
691+
},
692+
});
693+
694+
cache.writeFragment({
695+
id: "Book:031648637X",
696+
fragment: bookAuthorFragment,
697+
data: {
698+
...fragmentResult,
699+
author: {
700+
__typename: "Author",
701+
name: "J.K. Rowling",
702+
},
703+
},
704+
});
705+
706+
expect(cache.extract(true)).toEqual(snapshotWithBothNames);
707+
708+
expect(cache.retain("Author:Robert Galbraith")).toBe(2);
709+
710+
expect(cache.gc()).toEqual([]);
711+
712+
expect(cache.release("Author:Robert Galbraith")).toBe(1);
713+
expect(cache.release("Author:Robert Galbraith")).toBe(0);
714+
715+
expect(cache.gc()).toEqual([
716+
"Author:Robert Galbraith",
717+
]);
718+
719+
// If you're ever tempted to do this, you probably want to use cache.clear()
720+
// instead, but evicting the ROOT_QUERY should work at least.
721+
expect(cache.evict({
722+
rootId: "ROOT_QUERY",
723+
query,
724+
})).toEqual({
725+
success: true,
726+
});
727+
728+
expect(cache.extract(true)).toEqual({
729+
"Book:031648637X": {
730+
__typename: "Book",
731+
author: {
732+
__ref: "Author:J.K. Rowling",
733+
},
734+
title: "The Cuckoo's Calling",
735+
},
736+
"Author:J.K. Rowling": {
737+
__typename: "Author",
738+
name: "J.K. Rowling",
739+
},
740+
});
741+
742+
// The book has been retained a couple of times since we've written it
743+
// directly, but J.K. has never been directly written.
744+
expect(cache.release("Book:031648637X")).toBe(1);
745+
expect(cache.release("Book:031648637X")).toBe(0);
746+
747+
expect(cache.gc().sort()).toEqual([
748+
"Author:J.K. Rowling",
749+
"Book:031648637X",
750+
]);
751+
});
572752
});

src/cache/inmemory/entityCache.ts

+23-3
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export abstract class EntityCache implements NormalizedCache {
4646
return { ...this.data };
4747
}
4848

49+
public has(dataId: string): boolean {
50+
return hasOwn.call(this.data, dataId);
51+
}
52+
4953
public get(dataId: string): StoreObject {
5054
if (this.depend) this.depend(dataId);
5155
return this.data[dataId]!;
@@ -60,9 +64,7 @@ export abstract class EntityCache implements NormalizedCache {
6064
}
6165

6266
public delete(dataId: string): void {
63-
if (this instanceof Layer) {
64-
this.data[dataId] = void 0;
65-
} else delete this.data[dataId];
67+
delete this.data[dataId];
6668
delete this.refs[dataId];
6769
if (this.depend) this.depend.dirty(dataId);
6870
}
@@ -252,6 +254,24 @@ class Layer extends EntityCache {
252254
};
253255
}
254256

257+
public has(dataId: string): boolean {
258+
// Because the Layer implementation of the delete method uses void 0 to
259+
// indicate absence, that's what we need to check for here, rather than
260+
// calling super.has(dataId).
261+
if (hasOwn.call(this.data, dataId) && this.data[dataId] === void 0) {
262+
return false;
263+
}
264+
return this.parent.has(dataId);
265+
}
266+
267+
public delete(dataId: string): void {
268+
super.delete(dataId);
269+
// In case this.parent (or one of its ancestors) has an entry for this ID,
270+
// we need to shadow it with an undefined value, or it might be inherited
271+
// by the Layer#get method.
272+
this.data[dataId] = void 0;
273+
}
274+
255275
// All the other inherited accessor methods work as-is, but the get method
256276
// needs to fall back to this.parent.get when accessing a missing dataId.
257277
public get(dataId: string): StoreObject {

src/cache/inmemory/inMemoryCache.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import './fixPolyfills';
33

44
import { DocumentNode } from 'graphql';
55
import { wrap } from 'optimism';
6-
import { InvariantError } from 'ts-invariant';
76
import { KeyTrie } from 'optimism';
87

98
import { Cache, ApolloCache, Transaction } from '../core';
@@ -201,7 +200,14 @@ export class InMemoryCache extends ApolloCache<NormalizedCacheObject> {
201200
}
202201

203202
public evict(query: Cache.EvictOptions): Cache.EvictionResult {
204-
throw new InvariantError(`eviction is not implemented on InMemory Cache`);
203+
if (this.optimisticData.has(query.rootId)) {
204+
// Note that this deletion does not trigger a garbage collection, which
205+
// is convenient in cases where you want to evict multiple entities before
206+
// performing a single garbage collection.
207+
this.optimisticData.delete(query.rootId);
208+
return { success: !this.optimisticData.has(query.rootId) };
209+
}
210+
return { success: false };
205211
}
206212

207213
public reset(): Promise<void> {

src/cache/inmemory/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export declare type IdGetter = (
1616
* StoreObjects from the cache
1717
*/
1818
export interface NormalizedCache {
19+
has(dataId: string): boolean;
1920
get(dataId: string): StoreObject;
2021
set(dataId: string, value: StoreObject): void;
2122
delete(dataId: string): void;

0 commit comments

Comments
 (0)