Skip to content

Commit 4504596

Browse files
committedJul 7, 2021
docs(persistence): add documentation and an example for persistence mapping
1 parent dc69b30 commit 4504596

File tree

9 files changed

+510
-87
lines changed

9 files changed

+510
-87
lines changed
 

‎README.md

+25-18
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,21 @@ persistor.getLogs(print);
157157
persistor.getSize();
158158
```
159159
160+
Additionally, you can control what portions of your cache are persisted by passing
161+
a `persistenceMapper` function as an optional paramemter to the `CachePersistor`. E.g.
162+
163+
```ts
164+
const persistor = new CachePersistor({
165+
...
166+
persistenceMapper: async (data: any) => {
167+
// filter your cached data and queries
168+
return filteredData;
169+
},
170+
})
171+
```
172+
173+
Take a look at the [examples](./examples/react-native/src/persistence/persistenceMapper.ts)
174+
160175
### Custom Triggers
161176
162177
For control over persistence timing, provide a function to the `trigger` option.
@@ -210,16 +225,16 @@ including:
210225
211226
*`AsyncStorage`
212227
[does not support](https://github.com/facebook/react-native/issues/12529#issuecomment-345326643)
213-
individual values in excess of 2 MB on Android. If you set `maxSize` to more than 2 MB or to `false`,
228+
individual values in excess of 2 MB on Android. If you set `maxSize` to more than 2 MB or to `false`,
214229
use a different storage provider, such as
215-
[`react-native-mmkv-storage`](https://github.com/ammarahm-ed/react-native-mmkv-storage) or
230+
[`react-native-mmkv-storage`](https://github.com/ammarahm-ed/react-native-mmkv-storage) or
216231
[`redux-persist-fs-storage`](https://github.com/leethree/redux-persist-fs-storage).
217232
218233
### Using other storage providers
219234
220-
`apollo3-cache-persist` supports stable versions of storage providers mentioned above.
235+
`apollo3-cache-persist` supports stable versions of storage providers mentioned above.
221236
If you want to use other storage provider, or there's a breaking change in `next` version of supported provider,
222-
you can create your own wrapper. For an example of a simple wrapper have a look at [`AsyncStorageWrapper`](./src/storageWrappers/AsyncStorageWrapper.ts).
237+
you can create your own wrapper. For an example of a simple wrapper have a look at [`AsyncStorageWrapper`](./src/storageWrappers/AsyncStorageWrapper.ts).
223238
224239
If you found that stable version of supported provider is no-longer compatible, please [submit an issue or a Pull Request](https://github.com/apollographql/apollo-cache-persist/blob/master/CONTRIBUTING.md#issues).
225240
@@ -280,7 +295,7 @@ const App = () => {
280295

281296
init().catch(console.error);
282297
}, []);
283-
298+
284299
if (!client) {
285300
return <h2>Initializing app...</h2>;
286301
}
@@ -318,15 +333,12 @@ persistCacheSync({
318333

319334
#### I need to ensure certain data is not persisted. How do I filter my cache?
320335

321-
Unfortunately, this is not yet possible. You can only persist and restore the
322-
cache in its entirety.
336+
You can optionally pass a `persistenceMapper` function to the `CachePersistor` which
337+
will allow you to control what parts of the Apollo Client cache get persisted. Please
338+
refer to the [Advanced Usage of the `CachePersistor`](#using-cachepersistor) for more
339+
details.
323340

324-
This library depends upon the `extract` and `persist` methods defined upon the
325-
cache interface in Apollo Client 2.0. The payload returned and consumed by these
326-
methods is opaque and differs from cache to cache. As such, we cannot reliably
327-
transform the output.
328-
329-
Alternatives have been recommended in
341+
Other alternatives have been recommended in
330342
[#2](https://github.com/apollographql/apollo3-cache-persist/issues/2#issuecomment-350823835),
331343
including using logic in your UI to filter potentially-outdated information.
332344
Furthermore, the [`maxSize` option](#additional-options) and
@@ -341,11 +353,6 @@ The background task would start with an empty cache, query the most important
341353
data from your GraphQL API, and then persist. This strategy has the added
342354
benefit of ensuring the cache is loaded with fresh data when your app launches.
343355

344-
Finally, it's worth mentioning that the Apollo community is in the early stages
345-
of designing fine-grained cache controls, including the ability to utilize
346-
directives and metadata to control cache policy on a per-key basis, so the
347-
answer to this question will eventually change.
348-
349356
#### I've had a breaking schema change. How do I migrate or purge my cache?
350357

351358
For the same reasons given in the preceding answer, it's not possible to migrate

‎examples/react-native/App.tsx

+11-68
Original file line numberDiff line numberDiff line change
@@ -25,68 +25,18 @@ import {
2525
InMemoryCache,
2626
NormalizedCacheObject,
2727
useQuery,
28+
createHttpLink,
2829
} from '@apollo/client';
2930
import {AsyncStorageWrapper, CachePersistor} from 'apollo3-cache-persist';
3031
import AsyncStorage from '@react-native-async-storage/async-storage';
3132

32-
const launchesGQL = gql`
33-
query LaunchesQuery {
34-
launches(limit: 10) {
35-
id
36-
mission_name
37-
details
38-
launch_date_utc
39-
}
40-
}
41-
`;
42-
43-
type LaunchesQuery = {
44-
launches: {
45-
id: string;
46-
mission_name: string;
47-
details: string;
48-
launch_date_utc: string;
49-
}[];
50-
};
51-
52-
const Launches = () => {
53-
const {error, data, loading} = useQuery<LaunchesQuery>(launchesGQL, {
54-
fetchPolicy: 'cache-and-network',
55-
});
56-
57-
if (!data) {
58-
// we don't have data yet
59-
60-
if (loading) {
61-
// but we're loading some
62-
return <Text style={styles.heading}>Loading initial data...</Text>;
63-
}
64-
if (error) {
65-
// and we have an error
66-
return <Text style={styles.heading}>Error loading data :(</Text>;
67-
}
68-
return <Text style={styles.heading}>Unknown error :(</Text>;
69-
}
70-
71-
return (
72-
<ScrollView>
73-
{loading ? (
74-
<Text style={styles.heading}>Loading fresh data...</Text>
75-
) : null}
76-
{data.launches.map(launch => (
77-
<View key={launch.id} style={styles.item}>
78-
<Text style={styles.mission}>{launch.mission_name}</Text>
79-
<Text style={styles.launchDate}>
80-
{new Date(launch.launch_date_utc).toLocaleString()}
81-
</Text>
82-
</View>
83-
))}
84-
</ScrollView>
85-
);
86-
};
33+
import { Launches } from './src/Launches';
34+
import { Ships } from './src/Ships';
35+
import { persistenceMapper, createPersistLink } from './src/persistence';
8736

8837
const App = () => {
8938
const [client, setClient] = useState<ApolloClient<NormalizedCacheObject>>();
39+
const [display, setDisplay] = useState<number>(0);
9040
const [persistor, setPersistor] = useState<
9141
CachePersistor<NormalizedCacheObject>
9242
>();
@@ -99,12 +49,15 @@ const App = () => {
9949
storage: new AsyncStorageWrapper(AsyncStorage),
10050
debug: __DEV__,
10151
trigger: 'write',
52+
persistenceMapper,
10253
});
10354
await newPersistor.restore();
10455
setPersistor(newPersistor);
56+
const persistLink = createPersistLink();
57+
const httpLink = createHttpLink({ uri: 'https://api.spacex.land/graphql' });
10558
setClient(
10659
new ApolloClient({
107-
uri: 'https://api.spacex.land/graphql',
60+
link: persistLink.concat(httpLink),
10861
cache,
10962
}),
11063
);
@@ -132,9 +85,10 @@ const App = () => {
13285
<ApolloProvider client={client}>
13386
<SafeAreaView style={{...StyleSheet.absoluteFillObject}}>
13487
<View style={styles.content}>
135-
<Launches />
88+
{display % 2 === 0 ? <Launches /> : <Ships />}
13689
</View>
13790
<View style={styles.controls}>
91+
<Button title={`Show ${display % 2 ? 'Launches' : 'Ships' }`} onPress={() => setDisplay(display + 1)} />
13892
<Button title={'Clear cache'} onPress={clearCache} />
13993
<Button title={'Reload app (requires dev mode)'} onPress={reload} />
14094
</View>
@@ -144,17 +98,6 @@ const App = () => {
14498
};
14599

146100
const styles = StyleSheet.create({
147-
heading: {
148-
padding: 16,
149-
fontWeight: 'bold',
150-
},
151-
item: {
152-
padding: 16,
153-
},
154-
mission: {},
155-
launchDate: {
156-
fontSize: 12,
157-
},
158101
content: {flex: 1},
159102
controls: {flex: 0},
160103
});

‎examples/react-native/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"apollo3-cache-persist": "^0.9.1",
1616
"graphql": "^15.4.0",
1717
"react": "16.13.1",
18-
"react-native": "0.63.4"
18+
"react-native": "0.63.4",
19+
"traverse": "^0.6.6"
1920
},
2021
"devDependencies": {
2122
"@babel/core": "^7.8.4",
+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Sample React Native App
3+
* https://github.com/facebook/react-native
4+
*
5+
* Generated with the TypeScript template
6+
* https://github.com/react-native-community/react-native-template-typescript
7+
*
8+
* @format
9+
*/
10+
11+
import React, {useCallback, useEffect, useState} from 'react';
12+
import {
13+
ScrollView,
14+
StyleSheet,
15+
Text,
16+
View,
17+
} from 'react-native';
18+
import {
19+
gql,
20+
useQuery,
21+
} from '@apollo/client';
22+
23+
const launchesGQL = gql`
24+
query LaunchesQuery {
25+
launches(limit: 10) @persist {
26+
id
27+
details
28+
mission_name
29+
launch_date_utc
30+
}
31+
}
32+
`;
33+
34+
type LaunchesQuery = {
35+
launches: {
36+
id: string;
37+
mission_name: string;
38+
details: string;
39+
launch_date_utc: string;
40+
}[];
41+
};
42+
43+
export const Launches = () => {
44+
const {error, data, loading} = useQuery<LaunchesQuery>(launchesGQL, {
45+
fetchPolicy: 'cache-first',
46+
});
47+
48+
if (!data) {
49+
// we don't have data yet
50+
51+
if (loading) {
52+
// but we're loading some
53+
return <Text style={styles.heading}>Loading initial data...</Text>;
54+
}
55+
if (error) {
56+
// and we have an error
57+
return <Text style={styles.heading}>Error loading data :(</Text>;
58+
}
59+
return <Text style={styles.heading}>Unknown error :(</Text>;
60+
}
61+
62+
return (
63+
<ScrollView>
64+
{loading ? (
65+
<Text style={styles.heading}>Loading fresh data...</Text>
66+
) : null}
67+
{data.launches.map(launch => (
68+
<View key={launch.id} style={styles.item}>
69+
<Text style={styles.mission}>{launch.mission_name}</Text>
70+
<Text style={styles.launchDate}>
71+
{new Date(launch.launch_date_utc).toLocaleString()}
72+
</Text>
73+
</View>
74+
))}
75+
</ScrollView>
76+
);
77+
};
78+
79+
80+
const styles = StyleSheet.create({
81+
heading: {
82+
padding: 16,
83+
fontWeight: 'bold',
84+
},
85+
item: {
86+
padding: 16,
87+
},
88+
mission: {},
89+
launchDate: {
90+
fontSize: 12,
91+
},
92+
});

‎examples/react-native/src/Ships.tsx

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Sample React Native App
3+
* https://github.com/facebook/react-native
4+
*
5+
* Generated with the TypeScript template
6+
* https://github.com/react-native-community/react-native-template-typescript
7+
*
8+
* @format
9+
*/
10+
11+
import React, {useCallback, useEffect, useState} from 'react';
12+
import {
13+
ScrollView,
14+
StyleSheet,
15+
Text,
16+
View,
17+
} from 'react-native';
18+
import {
19+
gql,
20+
useQuery,
21+
} from '@apollo/client';
22+
23+
const shipsGQL = gql`
24+
query ShipsQuery {
25+
ships(limit: 10) {
26+
id
27+
name
28+
type
29+
home_port
30+
}
31+
}
32+
`;
33+
34+
type ShipsQuery = {
35+
ships: {
36+
id: string;
37+
name: string;
38+
type: string;
39+
home_port: string;
40+
}[];
41+
}
42+
43+
export const Ships = () => {
44+
const {error, data, loading} = useQuery<ShipsQuery>(shipsGQL, {
45+
fetchPolicy: 'cache-and-network',
46+
});
47+
48+
if (!data) {
49+
// we don't have data yet
50+
51+
if (loading) {
52+
// but we're loading some
53+
return <Text style={styles.heading}>Loading initial data...</Text>;
54+
}
55+
if (error) {
56+
// and we have an error
57+
return <Text style={styles.heading}>Error loading data :(</Text>;
58+
}
59+
return <Text style={styles.heading}>Unknown error :(</Text>;
60+
}
61+
62+
return (
63+
<ScrollView>
64+
{loading ? (
65+
<Text style={styles.heading}>Loading fresh data...</Text>
66+
) : null}
67+
{data.ships.map(ship => (
68+
<View key={ship.id} style={styles.item}>
69+
<Text>{ship.name}</Text>
70+
<Text style={styles.subtitle}>
71+
{ship.type}, {ship.home_port}
72+
</Text>
73+
</View>
74+
))}
75+
</ScrollView>
76+
);
77+
};
78+
79+
80+
const styles = StyleSheet.create({
81+
heading: {
82+
padding: 16,
83+
fontWeight: 'bold',
84+
},
85+
item: {
86+
padding: 16,
87+
},
88+
subtitle: {
89+
fontSize: 12,
90+
},
91+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './persistenceMapper';
2+
export * from './persistLink';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Taken from https://github.com/TallerWebSolutions/apollo-cache-instorage
3+
*/
4+
import { visit } from 'graphql';
5+
import { ApolloLink } from '@apollo/client';
6+
import traverse from 'traverse';
7+
8+
import { extractPersistDirectivePaths, hasPersistDirective } from './transform';
9+
10+
/**
11+
* Given a data result object path, return the equivalent query selection path.
12+
*
13+
* @param {Array} path The data result object path. i.e.: ["a", 0, "b"]
14+
* @return {String} the query selection path. i.e.: "a.b"
15+
*/
16+
const toQueryPath = path => path.filter(key => isNaN(Number(key))).join('.')
17+
18+
/**
19+
* Given a data result object, attach __persist values.
20+
*/
21+
const attachPersists = (paths, object) => {
22+
const queryPaths = paths.map(toQueryPath)
23+
24+
return traverse(object).map(function () {
25+
if (
26+
!this.isRoot &&
27+
this.node &&
28+
typeof this.node === 'object' &&
29+
Object.keys(this.node).length &&
30+
!Array.isArray(this.node)
31+
) {
32+
const path = toQueryPath(this.path)
33+
34+
this.update({
35+
__persist: Boolean(
36+
queryPaths.find(
37+
queryPath =>
38+
queryPath.indexOf(path) === 0 || path.indexOf(queryPath) === 0
39+
)
40+
),
41+
...this.node
42+
})
43+
}
44+
})
45+
}
46+
47+
class PersistLink extends ApolloLink {
48+
/**
49+
* InStorageCache shouldPersist implementation for a __persist field validation.
50+
*/
51+
static shouldPersist (op, dataId, data) {
52+
// console.log(dataId, data)
53+
return dataId === 'ROOT_QUERY' || (!data || !!data.__persist)
54+
}
55+
56+
/**
57+
* InStorageCache addPersistField implementation to check for @perist directives.
58+
*/
59+
static addPersistField = doc => hasPersistDirective(doc)
60+
61+
constructor () {
62+
super()
63+
this.directive = 'persist'
64+
}
65+
66+
/**
67+
* Link query requester.
68+
*/
69+
request = (operation, forward) => {
70+
const { query, paths } = extractPersistDirectivePaths(
71+
operation.query,
72+
this.directive
73+
)
74+
// Replace query with one without @persist directives.
75+
operation.query = query
76+
77+
// Remove requesting __persist fields.
78+
operation.query = visit(operation.query, {
79+
Field: ({ name: { value: name } }, key, parent, path, ancestors) => {
80+
if (name === '__persist') {
81+
return null
82+
}
83+
}
84+
})
85+
86+
return forward(operation).map(result => {
87+
if (result.data) {
88+
result.data = attachPersists(paths, result.data)
89+
}
90+
91+
return result
92+
})
93+
}
94+
}
95+
96+
const createPersistLink = config => new PersistLink(config)
97+
98+
export { PersistLink, createPersistLink }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export const persistenceMapper = (data: any) => {
2+
const parsed = JSON.parse(data);
3+
4+
const mapped = {};
5+
const persistEntities = [];
6+
const rootQuery = parsed['ROOT_QUERY'];
7+
8+
mapped['ROOT_QUERY'] = Object.keys(rootQuery).reduce((obj, key) => {
9+
if (key === '__typename') return obj;
10+
11+
if (/@persist$/.test(key)) {
12+
obj[key] = rootQuery[key];
13+
const entities = rootQuery[key].map(item => item.__ref);
14+
persistEntities.push(...entities);
15+
}
16+
17+
return obj;
18+
}, { __typename: 'Query' });
19+
20+
persistEntities.reduce((obj, key) => {
21+
obj[key] = parsed[key];
22+
return obj;
23+
}, mapped);
24+
25+
return JSON.stringify(mapped);
26+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Taken from https://github.com/TallerWebSolutions/apollo-cache-instorage
3+
*/
4+
import { visit, BREAK } from 'graphql';
5+
import { checkDocument, cloneDeep } from '@apollo/client';
6+
7+
const PERSIST_FIELD = {
8+
kind: 'Field',
9+
name: {
10+
kind: 'Name',
11+
value: '__persist'
12+
}
13+
}
14+
15+
const addPersistFieldToSelectionSet = (selectionSet, isRoot = false) => {
16+
if (selectionSet.selections) {
17+
if (!isRoot) {
18+
const alreadyHasThisField = selectionSet.selections.some(selection => {
19+
return (
20+
selection.kind === 'Field' && selection.name.value === '__typename'
21+
)
22+
})
23+
24+
if (!alreadyHasThisField) {
25+
selectionSet.selections.push(PERSIST_FIELD)
26+
}
27+
}
28+
29+
selectionSet.selections.forEach(selection => {
30+
// Must not add __typename if we're inside an introspection query
31+
if (selection.kind === 'Field') {
32+
if (
33+
selection.name.value.lastIndexOf('__', 0) !== 0 &&
34+
selection.selectionSet
35+
) {
36+
addPersistFieldToSelectionSet(selection.selectionSet)
37+
}
38+
}
39+
else if (selection.kind === 'InlineFragment') {
40+
if (selection.selectionSet) {
41+
addPersistFieldToSelectionSet(selection.selectionSet)
42+
}
43+
}
44+
})
45+
}
46+
}
47+
48+
const addPersistFieldToDocument = doc => {
49+
checkDocument(doc)
50+
const docClone = cloneDeep(doc)
51+
52+
docClone.definitions.forEach(definition => {
53+
const isRoot = definition.kind === 'OperationDefinition'
54+
addPersistFieldToSelectionSet(definition.selectionSet, isRoot)
55+
})
56+
57+
return docClone
58+
}
59+
60+
const extractPersistDirectivePaths = (originalQuery, directive = 'persist') => {
61+
const paths = []
62+
const fragmentPaths = {}
63+
const fragmentPersistPaths = {}
64+
65+
const query = visit(originalQuery, {
66+
FragmentSpread: (
67+
{ name: { value: name } },
68+
key,
69+
parent,
70+
path,
71+
ancestors
72+
) => {
73+
const root = ancestors.find(
74+
({ kind }) =>
75+
kind === 'OperationDefinition' || kind === 'FragmentDefinition'
76+
)
77+
78+
const rootKey =
79+
root.kind === 'FragmentDefinition' ? root.name.value : '$ROOT'
80+
81+
const fieldPath = ancestors
82+
.filter(({ kind }) => kind === 'Field')
83+
.map(({ name: { value: name } }) => name)
84+
85+
fragmentPaths[name] = [rootKey].concat(fieldPath)
86+
},
87+
Directive: ({ name: { value: name } }, key, parent, path, ancestors) => {
88+
if (name === directive) {
89+
const fieldPath = ancestors
90+
.filter(({ kind }) => kind === 'Field')
91+
.map(({ name: { value: name } }) => name)
92+
93+
const fragmentDefinition = ancestors.find(
94+
({ kind }) => kind === 'FragmentDefinition'
95+
)
96+
97+
// If we are inside a fragment, we must save the reference.
98+
if (fragmentDefinition) {
99+
fragmentPersistPaths[fragmentDefinition.name.value] = fieldPath
100+
}
101+
else if (fieldPath.length) {
102+
paths.push(fieldPath)
103+
}
104+
105+
return null
106+
}
107+
}
108+
})
109+
110+
// In case there are any FragmentDefinition items, we need to combine paths.
111+
if (Object.keys(fragmentPersistPaths).length) {
112+
visit(originalQuery, {
113+
FragmentSpread: (
114+
{ name: { value: name } },
115+
key,
116+
parent,
117+
path,
118+
ancestors
119+
) => {
120+
if (fragmentPersistPaths[name]) {
121+
let fieldPath = ancestors
122+
.filter(({ kind }) => kind === 'Field')
123+
.map(({ name: { value: name } }) => name)
124+
125+
fieldPath = fieldPath.concat(fragmentPersistPaths[name])
126+
127+
let fragment = name
128+
let parent = fragmentPaths[fragment][0]
129+
130+
while (parent && parent !== '$ROOT' && fragmentPaths[parent]) {
131+
fieldPath = fragmentPaths[parent].slice(1).concat(fieldPath)
132+
parent = fragmentPaths[parent][0]
133+
}
134+
135+
paths.push(fieldPath)
136+
}
137+
}
138+
})
139+
}
140+
141+
return { query, paths }
142+
}
143+
144+
const hasPersistDirective = doc => {
145+
let hasDirective = false
146+
147+
visit(doc, {
148+
Directive: ({ name: { value: name } }) => {
149+
if (name === 'persist') {
150+
hasDirective = true
151+
return BREAK
152+
}
153+
}
154+
})
155+
156+
return hasDirective
157+
}
158+
159+
export {
160+
addPersistFieldToDocument,
161+
extractPersistDirectivePaths,
162+
hasPersistDirective
163+
}

0 commit comments

Comments
 (0)
Please sign in to comment.