Skip to content

Commit ff8be14

Browse files
committed
feat(refetch): support an array of conditions for handling association changes
1 parent 1403ffb commit ff8be14

File tree

3 files changed

+312
-13
lines changed

3 files changed

+312
-13
lines changed

README.md

+168-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
66
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
77

8-
Handling Apollo cache updates after creating and deleting objects remains a
8+
Handling Apollo cache updates after creating and deleting objects, or
9+
associating and dissociating objects, remains a
910
[poorly solved problem](https://github.com/apollographql/apollo-client/issues/899).
1011
`update` and `refetchQueries` props on `Mutation`s couple different areas of
1112
your app in a way you probably don't want, and they don't scale well as you add
@@ -16,6 +17,19 @@ and cache code.
1617

1718
Until that happens, this is probably your best bet!
1819

20+
# Table of Contents
21+
22+
* [How it works](#how-it-works)
23+
* [Current limitations](#current-limitations)
24+
* [ES environment requirements](#es-environment-requirements)
25+
* [Type metadata usage](#type-metadata-usage)
26+
* [Handling Deletions](#handling-deletions)
27+
* [Handling Creation](#handling-creation)
28+
* [Handling associations being broken](#handling-associations-being-broken)
29+
* [Handling associations being created](#handling-associations-being-created)
30+
* [API](#api)
31+
+ [`refetch(client, typename, [ids], [idField])`](#refetchclient-typename-ids-idfield)
32+
1933
## How it works
2034

2135
After you delete an object, you tell `apollo-magic-refetch` what `typename` and
@@ -140,6 +154,154 @@ const CreateDeviceFormContainer = () => (
140154
)
141155
```
142156

157+
## Handling associations being broken
158+
159+
In this example, a view shows a list of `Organization`s, each containing a
160+
sublist of `User`s. When one or more users is removed from an organization,
161+
it makes the following call:
162+
```js
163+
refetch(client, [
164+
['User', userIds],
165+
['Organization', organizationId],
166+
])
167+
```
168+
Passing an array to `refetch` means to only refetch queries containing all of
169+
the conditions in the array. So the query below would be refetched, but a query
170+
containing only `Organizations` or a query containing only `User`s would not.
171+
172+
```js
173+
import * as React from 'react'
174+
import gql from 'graphql-tag'
175+
import refetch from 'apollo-magic-refetch'
176+
import {Mutation, ApolloConsumer} from 'react-apollo'
177+
import OrganizationView from './OrganizationView'
178+
179+
const query = gql`
180+
query {
181+
Organizations {
182+
id
183+
name
184+
Users {
185+
id
186+
username
187+
}
188+
}
189+
}
190+
`
191+
192+
const mutation = gql`
193+
mutation removeUsersFromOrganization($organizationId: Int!, $userIds: [Int!]!) {
194+
result: removeUsersFromOrganization(organizationId: $organizationId, userIds: $userIds) {
195+
organizationId
196+
userIds
197+
}
198+
}
199+
`
200+
201+
const OrganizationViewContainer = ({organization: {id, name, Users}}) => (
202+
<ApolloConsumer>
203+
{client => (
204+
<Mutation
205+
mutation={mutation}
206+
update={(cache, {data: {result: {organizationId, userIds}}}) =>
207+
refetch(client, [
208+
['User', userIds],
209+
['Organization', organizationId],
210+
])
211+
}
212+
>
213+
{removeUsersFromOrganization => (
214+
<OrganizationView
215+
organization={organization}
216+
onRemoveUsers={userIds => removeUsersFromOrganization({
217+
variables: {organizationId, userIds},
218+
})}
219+
/>
220+
)}
221+
</Mutation>
222+
)}
223+
</ApolloConsumer>
224+
)
225+
226+
const OrganizationsViewContainer = () => (
227+
<Query query={query}>
228+
{({data}) => {
229+
const {Organizations} = data || {}
230+
if (!Organizations) return <div />
231+
return (
232+
<div>
233+
<h1>Organizations</h1>
234+
{Organizations.map((organization) => (
235+
<OrganizationViewContainer
236+
key={organization.id}
237+
organization={organization}
238+
/>
239+
)}
240+
</div>
241+
)
242+
}}
243+
</Query>
244+
)
245+
```
246+
247+
## Handling associations being created
248+
249+
Assuming the same `Organization`s/`User`s schema as above, the example performs
250+
the necessary refetches when a user is created and added to an organization:
251+
```js
252+
refetch(client, [
253+
['User'],
254+
['Organization', organizationId],
255+
])
256+
```
257+
In this case no `ids` are given for `User`, so any query containing the an
258+
`Organization` with the given `organizationId` in its results and selecting any
259+
`User`s would be refetched. (This doesn't perfectly exclude cases that fetch
260+
Users and Organizations separately, instead of one nested inside the other, but
261+
it's better than nothing).
262+
263+
```js
264+
import * as React from 'react'
265+
import gql from 'graphql-tag'
266+
import refetch from 'apollo-magic-refetch'
267+
import {Mutation, ApolloConsumer} from 'react-apollo'
268+
import CreateUserForm from './CreateUserForm'
269+
270+
const mutation = gql`
271+
mutation createUser($organizationId: Int!, $values: CreateUser!) {
272+
result: createUser(organizationId: $organizationId, values: $values) {
273+
organizationId
274+
id
275+
username
276+
}
277+
}
278+
`
279+
280+
const CreateUserFormContainer = ({organizationId}) => (
281+
<ApolloConsumer>
282+
{client => (
283+
<Mutation
284+
mutation={mutation}
285+
update={() =>
286+
refetch(client, [
287+
['User'],
288+
['Organization', organizationId],
289+
])
290+
}
291+
>
292+
{createUser => (
293+
<CreateUserForm
294+
onSubmit={values => createUser({
295+
variables: {organizationId, values},
296+
})}
297+
/>
298+
)}
299+
</Mutation>
300+
)}
301+
</ApolloConsumer>
302+
)
303+
```
304+
143305
## API
144306
145307
### `refetch(client, typename, [ids], [idField])`
@@ -150,9 +312,12 @@ const CreateDeviceFormContainer = () => (
150312
151313
The `ApolloClient` in which to scan active queries.
152314
153-
##### `typename: string`
315+
##### `typenameOrTerms: string | Array<Term>`
154316
155-
The `__typename` of the GraphQL type that was created or deleted.
317+
The `__typename` of the GraphQL type that was created or deleted, or an array of
318+
`[typename, ids, idField]` tuples (`ids` and `idField` are optional). If an
319+
array is given, a query must match all of the conditions in the array to be
320+
refetched.
156321
157322
##### `ids: any (*optional*)`
158323

src/index.js

+36-7
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,51 @@ function normalizeIds(ids: any): Set<any> {
1111
return new Set([ids])
1212
}
1313

14-
export default async function refetch(client: ApolloClient, typename: string, ids?: ?any, idField?: string = 'id'): Promise<any> {
14+
type Term = [string, any, ?string] | [string, any] | [string]
15+
16+
function every<T>(array: $ReadOnlyArray<T>, predicate: (elem: T) => boolean): boolean {
17+
for (let elem of array) {
18+
if (!predicate(elem)) return false
19+
}
20+
return true
21+
}
22+
23+
export default async function refetch(
24+
client: ApolloClient,
25+
typenameOrTerms: string | $ReadOnlyArray<Term>,
26+
ids?: ?any,
27+
idField?: string
28+
): Promise<any> {
1529
const types: Types = await getSchemaTypes(client)
1630

17-
const finalIds = ids != null ? normalizeIds(ids) : null
31+
let terms
32+
if (typeof typenameOrTerms === 'string') {
33+
terms = [[typenameOrTerms, ids, idField]]
34+
} else if (Array.isArray(typenameOrTerms)) {
35+
terms = typenameOrTerms
36+
} else {
37+
throw new Error(`invalid typename or terms: ${typenameOrTerms}`)
38+
}
1839

1940
const {queryManager: {queries}} = client
2041
let promises = []
2142
for (let query of queries.values()) {
2243
const {document, observableQuery} = query
2344
if (!observableQuery) continue
2445
let data
25-
if (finalIds) {
26-
const currentResult = observableQuery.currentResult()
27-
if (currentResult) data = currentResult.data
28-
}
29-
if (doesQueryContain(document, types, typename, data, finalIds, idField)) {
46+
const currentResult = observableQuery.currentResult()
47+
if (currentResult) data = currentResult.data
48+
49+
if (every(terms, ([typename, ids, idField]: any) =>
50+
doesQueryContain(
51+
document,
52+
types,
53+
typename,
54+
data,
55+
ids != null ? normalizeIds(ids) : null,
56+
idField || 'id'
57+
)
58+
)) {
3059
promises.push(observableQuery.refetch())
3160
}
3261
}

test/integration.js

+108-3
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,12 @@ describe(`integration test`, function () {
9090
id: 3,
9191
username: 'bobilly',
9292
organizationIds: [2],
93-
}]
93+
}],
94+
[4, {
95+
id: 4,
96+
username: 'saget',
97+
organizationIds: [],
98+
}],
9499
])
95100

96101
const schema = makeExecutableSchema({
@@ -106,7 +111,17 @@ describe(`integration test`, function () {
106111
})
107112
})
108113

109-
it(`basic test`, async function (): Promise<void> {
114+
it(`throws when invalid typenameOrTerms is given`, async function (): Promise<void> {
115+
let error
116+
try {
117+
await refetch(client, (2: any))
118+
} catch (err) {
119+
error = err
120+
}
121+
expect(error).to.exist
122+
})
123+
124+
it(`handles deleted User`, async function (): Promise<void> {
110125
const query = gql`{
111126
orgs: Organizations {
112127
id
@@ -129,7 +144,7 @@ describe(`integration test`, function () {
129144
org.userIds = org.userIds.filter(id => id !== 2)
130145
}
131146

132-
await refetch(client, 'User', 2)
147+
await refetch(client, 'User', [2])
133148

134149
const {data: {orgs: finalOrgs}} = await observableQuery.currentResult()
135150

@@ -138,4 +153,94 @@ describe(`integration test`, function () {
138153
[3],
139154
])
140155
})
156+
it(`handles User removed from org`, async function (): Promise<void> {
157+
const query = gql`{
158+
orgs: Organizations {
159+
id
160+
name
161+
Users {
162+
id
163+
username
164+
}
165+
}
166+
}`
167+
168+
const usersQuery = gql`{
169+
Users {
170+
id
171+
username
172+
}
173+
}`
174+
175+
const observableQuery = client.watchQuery({query})
176+
observableQuery.subscribe({})
177+
await observableQuery.refetch()
178+
179+
const observableUsersQuery = client.watchQuery({query: usersQuery})
180+
observableUsersQuery.subscribe({})
181+
await observableUsersQuery.refetch()
182+
183+
const {data: {orgs}} = await observableQuery.currentResult()
184+
expect(orgs.map(({id}) => id)).to.deep.equal([...Organizations.keys()]);
185+
186+
(Users.get(2): any).organizationIds = [1];
187+
(Organizations.get(2): any).userIds = [3]
188+
189+
await refetch(client, [
190+
['User', 2],
191+
['Organization', 2],
192+
])
193+
194+
const {data: {orgs: finalOrgs}} = await observableQuery.currentResult()
195+
196+
expect(finalOrgs.map(({Users}) => Users.map(({id}) => id))).to.deep.equal([
197+
[1, 2],
198+
[3],
199+
])
200+
})
201+
it(`handles User added to org`, async function (): Promise<void> {
202+
const query = gql`{
203+
orgs: Organizations {
204+
id
205+
name
206+
Users {
207+
id
208+
username
209+
}
210+
}
211+
}`
212+
213+
const usersQuery = gql`{
214+
Users {
215+
id
216+
username
217+
}
218+
}`
219+
220+
const observableQuery = client.watchQuery({query})
221+
observableQuery.subscribe({})
222+
await observableQuery.refetch()
223+
224+
const observableUsersQuery = client.watchQuery({query: usersQuery})
225+
observableUsersQuery.subscribe({})
226+
await observableUsersQuery.refetch()
227+
228+
const {data: {orgs}} = await observableQuery.currentResult()
229+
expect(orgs.map(({id}) => id)).to.deep.equal([...Organizations.keys()]);
230+
231+
(Users.get(4): any).organizationIds.push(2);
232+
(Organizations.get(2): any).userIds.push(4)
233+
234+
await refetch(client, [
235+
['User'],
236+
['Organization', new Set([2])],
237+
])
238+
239+
const {data: {orgs: finalOrgs}} = await observableQuery.currentResult()
240+
241+
expect(finalOrgs.map(({Users}) => Users.map(({id}) => id))).to.deep.equal([
242+
[1, 2],
243+
[2, 3, 4],
244+
])
245+
})
141246
})

0 commit comments

Comments
 (0)