From a5db83c4fa328b0249720a11957aef2594ac05f0 Mon Sep 17 00:00:00 2001 From: Ian MacLeod Date: Sun, 10 Jul 2016 12:16:51 -0700 Subject: [PATCH] Introducing StoreFetchMiddleware By allowing users to rewrite data lookups from the store, they can satisfy behavior like #332 --- CHANGELOG.md | 1 + ambient.d.ts | 5 + package.json | 1 + src/QueryManager.ts | 11 ++ src/data/diffAgainstStore.ts | 20 ++- src/data/fetchMiddleware.ts | 55 ++++++++ src/index.ts | 11 ++ test/client.ts | 128 ++++++++++++++++++ .../browser/globals/apollo-client/index.d.ts | 5 + typings/main/globals/apollo-client/index.d.ts | 5 + 10 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 src/data/fetchMiddleware.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef8ab03dde..cb61fb13ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ This release has a minor version bump, which means npm will not automatically up ### v0.3.30 - Don't throw on unknown directives, instead just pass them through. This can open the door to implementing `@live`, `@defer`, and `@stream`, if coupled with some changes in the network layer. [PR #372](https://github.com/apollostack/apollo-client/pull/372) +- Added a `storeFetchMiddleware` option to `ApolloClient` that allows transformation of values returned from the store. Also exposes a `cachedFetchById` middleware to handle the common case of fetching cached resources by id. [PR #376](https://github.com/apollostack/apollo-client/pull/376) ### v0.3.29 diff --git a/ambient.d.ts b/ambient.d.ts index e5b5d1ff8a1..f4b43121bce 100644 --- a/ambient.d.ts +++ b/ambient.d.ts @@ -93,6 +93,11 @@ declare module 'lodash.identity' { export = main.identity; } +declare module 'lodash.every' { + import main = require('~lodash/index'); + export = main.every; +} + /* GRAPHQL diff --git a/package.json b/package.json index e2ac48b8a3c..02488e21eab 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "lodash.assign": "^4.0.8", "lodash.clonedeep": "^4.3.2", "lodash.countby": "^4.4.0", + "lodash.every": "^4.4.0", "lodash.forown": "^4.1.0", "lodash.has": "^4.3.1", "lodash.identity": "^3.0.0", diff --git a/src/QueryManager.ts b/src/QueryManager.ts index f545fe99a73..d0c9d3a3269 100644 --- a/src/QueryManager.ts +++ b/src/QueryManager.ts @@ -53,6 +53,10 @@ import { diffSelectionSetAgainstStore, } from './data/diffAgainstStore'; +import { + StoreFetchMiddleware, +} from './data/fetchMiddleware'; + import { MutationBehavior, } from './data/mutationResults'; @@ -191,6 +195,7 @@ export class QueryManager { private store: ApolloStore; private reduxRootKey: string; private queryTransformer: QueryTransformer; + private storeFetchMiddleware: StoreFetchMiddleware; private queryListeners: { [queryId: string]: QueryListener }; private idCounter = 0; @@ -222,6 +227,7 @@ export class QueryManager { store, reduxRootKey, queryTransformer, + storeFetchMiddleware, shouldBatch = false, batchInterval = 10, }: { @@ -229,6 +235,7 @@ export class QueryManager { store: ApolloStore, reduxRootKey: string, queryTransformer?: QueryTransformer, + storeFetchMiddleware?: StoreFetchMiddleware, shouldBatch?: Boolean, batchInterval?: number, }) { @@ -238,6 +245,7 @@ export class QueryManager { this.store = store; this.reduxRootKey = reduxRootKey; this.queryTransformer = queryTransformer; + this.storeFetchMiddleware = storeFetchMiddleware; this.pollingTimers = {}; this.batchInterval = batchInterval; this.queryListeners = {}; @@ -382,6 +390,7 @@ export class QueryManager { context: { store: this.getDataWithOptimisticResults(), fragmentMap: queryStoreValue.fragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, rootId: queryStoreValue.query.id, selectionSet: queryStoreValue.query.selectionSet, @@ -627,6 +636,7 @@ export class QueryManager { context: { store: this.store.getState()[this.reduxRootKey].data, fragmentMap: queryFragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, selectionSet: querySS.selectionSet, throwOnMissingField: false, @@ -734,6 +744,7 @@ export class QueryManager { context: { store: this.getApolloState().data, fragmentMap: queryFragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, rootId: querySS.id, selectionSet: querySS.selectionSet, diff --git a/src/data/diffAgainstStore.ts b/src/data/diffAgainstStore.ts index 6a6cb5f779c..3752cd68ef6 100644 --- a/src/data/diffAgainstStore.ts +++ b/src/data/diffAgainstStore.ts @@ -1,6 +1,7 @@ import isArray = require('lodash.isarray'); import isNull = require('lodash.isnull'); import isString = require('lodash.isstring'); +import isUndefined = require('lodash.isundefined'); import has = require('lodash.has'); import assign = require('lodash.assign'); @@ -15,6 +16,10 @@ import { NormalizedCache, } from './store'; +import { + StoreFetchMiddleware, +} from './fetchMiddleware'; + import { SelectionSetWithRoot, } from '../queries/store'; @@ -47,6 +52,7 @@ export interface DiffResult { export interface StoreContext { store: NormalizedCache; fragmentMap: FragmentMap; + fetchMiddleware?: StoreFetchMiddleware; } export function diffQueryAgainstStore({ @@ -250,7 +256,17 @@ function diffFieldAgainstStore({ const storeObj = context.store[rootId] || {}; const storeFieldKey = storeKeyNameFromField(field, variables); - if (! has(storeObj, storeFieldKey)) { + let storeValue, fieldMissing; + // Give the transformer a chance to yield a rewritten result. + if (context.fetchMiddleware) { + storeValue = context.fetchMiddleware(field, variables, context.store, () => storeObj[storeFieldKey]); + fieldMissing = isUndefined(storeValue); + } else { + storeValue = storeObj[storeFieldKey]; + fieldMissing = !has(storeObj, storeFieldKey); + } + + if (fieldMissing) { if (throwOnMissingField && included) { throw new Error(`Can't find field ${storeFieldKey} on object ${JSON.stringify(storeObj)}. Perhaps you want to use the \`returnPartialData\` option?`); @@ -261,8 +277,6 @@ Perhaps you want to use the \`returnPartialData\` option?`); }; } - const storeValue = storeObj[storeFieldKey]; - // Handle all scalar types here if (! field.selectionSet) { return { diff --git a/src/data/fetchMiddleware.ts b/src/data/fetchMiddleware.ts new file mode 100644 index 00000000000..361a96ec6db --- /dev/null +++ b/src/data/fetchMiddleware.ts @@ -0,0 +1,55 @@ +import every = require('lodash.every'); +import has = require('lodash.has'); + +import { + Field, +} from 'graphql'; + +import { + NormalizedCache, +} from './store'; + +// Middleware that is given an opportunity to rewrite results from the store. +// It should call `next()` to look up the default value. +export type StoreFetchMiddleware = ( + field: Field, + variables: {}, + store: NormalizedCache, + next: () => any +) => any; + +// StoreFetchMiddleware that special cases all parameterized queries containing +// either `id` or `ids` to retrieve nodes by those ids directly from the store. +// +// This allows the client to avoid an extra round trip when it is fetching a +// node by id that was previously fetched by a different query. +// +// NOTE: This middleware assumes that you are mapping data ids to the id of +// your nodes. E.g. `dataIdFromObject: value => value.id`. +export function cachedFetchById( + field: Field, + variables: {}, + store: NormalizedCache, + next: () => any +): any { + // Note that we are careful to _not_ return an id if it doesn't exist in the + // store! apollo-client assumes that if an id exists in the store, the node + // referenced must also exist. + if (field.arguments && field.arguments.length === 1) { + const onlyArg = field.arguments[0]; + if (onlyArg.name.value === 'id') { + const id = variables['id']; + if (has(store, id)) { + return id; + } + } else if (onlyArg.name.value === 'ids') { + const ids = variables['ids']; + if (every(ids, id => has(store, id))) { + return ids; + } + } + } + + // Otherwise, fall back to the regular behavior. + return next(); +} diff --git a/src/index.ts b/src/index.ts index 841f60763b3..c94443cf807 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,11 @@ import { addTypenameToSelectionSet, } from './queries/queryTransform'; +import { + cachedFetchById, + StoreFetchMiddleware, +} from './data/fetchMiddleware'; + import { MutationBehavior, MutationBehaviorReducerMap, @@ -71,6 +76,7 @@ export { readQueryFromStore, readFragmentFromStore, addTypenameToSelectionSet as addTypename, + cachedFetchById, writeQueryToStore, writeFragmentToStore, print as printAST, @@ -146,6 +152,7 @@ export default class ApolloClient { public queryManager: QueryManager; public reducerConfig: ApolloReducerConfig; public queryTransformer: QueryTransformer; + public storeFetchMiddleware: StoreFetchMiddleware; public shouldBatch: boolean; public shouldForceFetch: boolean; public dataId: IdGetter; @@ -158,6 +165,7 @@ export default class ApolloClient { initialState, dataIdFromObject, queryTransformer, + storeFetchMiddleware, shouldBatch = false, ssrMode = false, ssrForceFetchDelay = 0, @@ -169,6 +177,7 @@ export default class ApolloClient { initialState?: any, dataIdFromObject?: IdGetter, queryTransformer?: QueryTransformer, + storeFetchMiddleware?: StoreFetchMiddleware, shouldBatch?: boolean, ssrMode?: boolean, ssrForceFetchDelay?: number @@ -180,6 +189,7 @@ export default class ApolloClient { this.networkInterface = networkInterface ? networkInterface : createNetworkInterface('/graphql'); this.queryTransformer = queryTransformer; + this.storeFetchMiddleware = storeFetchMiddleware; this.shouldBatch = shouldBatch; this.shouldForceFetch = !(ssrMode || ssrForceFetchDelay > 0); this.dataId = dataIdFromObject; @@ -284,6 +294,7 @@ export default class ApolloClient { reduxRootKey: this.reduxRootKey, store, queryTransformer: this.queryTransformer, + storeFetchMiddleware: this.storeFetchMiddleware, shouldBatch: this.shouldBatch, batchInterval: this.batchInterval, }); diff --git a/test/client.ts b/test/client.ts index 6fbba359048..c1eca1e8843 100644 --- a/test/client.ts +++ b/test/client.ts @@ -55,6 +55,8 @@ import { import { addTypenameToSelectionSet } from '../src/queries/queryTransform'; +import { cachedFetchById } from '../src/data/fetchMiddleware'; + import mockNetworkInterface from './mocks/mockNetworkInterface'; import { getFragmentDefinitions } from '../src/queries/getFromAST'; @@ -801,6 +803,132 @@ describe('client', () => { }); }); + describe('store fetch middleware (with cachedFetchById)', () => { + + let fetchAll, fetchOne, fetchMany, tasks, flatTasks, client, requests; + beforeEach(() => { + fetchAll = gql` + query fetchAll { + tasks { + id + name + } + } + `; + fetchOne = gql` + query fetchOne($id: ID!) { + task(id: $id) { + id + name + } + } + `; + fetchMany = gql` + query fetchMany($ids: [ID]!) { + tasks(ids: $ids) { + id + name + } + } + `; + tasks = { + abc123: {id: 'abc123', name: 'Do stuff'}, + def456: {id: 'def456', name: 'Do things'}, + }; + flatTasks = Object.keys(tasks).map(k => tasks[k]); + requests = []; + const networkInterface: NetworkInterface = { + query(request: Request): Promise { + return new Promise((resolve) => { + requests.push(request); + if (request.operationName === 'fetchAll') { + resolve({ data: { tasks: flatTasks } }); + } else if (request.operationName === 'fetchMany') { + const ids = request.variables['ids']; + resolve({ data: { tasks: ids.map(i => tasks[i] || null) } }); + } else if (request.operationName === 'fetchOne') { + resolve({ data: { task: tasks[request.variables['id']] || null } }); + } + }); + }, + }; + client = new ApolloClient({ + networkInterface, + dataIdFromObject: (value) => (value).id, + storeFetchMiddleware: cachedFetchById, + }); + }); + + it('should support directly querying with an empty cache', () => { + return client.query({ query: fetchOne, variables: { id: 'abc123' } }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { task: tasks['abc123'] }); + assert.deepEqual(requests.map(r => r.operationName), ['fetchOne']); + }); + }); + + it('should support directly querying with cache lookups', () => { + return client.query({ query: fetchOne, variables: { id: 'abc123' } }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { task: tasks['abc123'] }); + return client.query({ query: fetchOne, variables: { id: 'abc123' } }); + }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { task: tasks['abc123'] }); + assert.deepEqual(requests.map(r => r.operationName), ['fetchOne']); + }); + }); + + it('should support rewrites from other queries', () => { + return client.query({ query: fetchAll }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { tasks: flatTasks }); + return client.query({ query: fetchOne, variables: { id: 'abc123' } }); + }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { task: tasks['abc123'] }); + assert.deepEqual(requests.map(r => r.operationName), ['fetchAll']); + }); + }); + + it('should handle cache misses when rewriting', () => { + return client.query({ query: fetchAll }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { tasks: flatTasks }); + return client.query({ query: fetchOne, variables: { id: 'badid' } }); + }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { task: null }); + assert.deepEqual(requests.map(r => r.operationName), ['fetchAll', 'fetchOne']); + }); + }); + + it('should handle bulk fetching from cache', () => { + return client.query({ query: fetchAll }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { tasks: flatTasks }); + return client.query({ query: fetchMany, variables: { ids: ['def456', 'abc123'] } }); + }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { tasks: [tasks['def456'], tasks['abc123']] }); + assert.deepEqual(requests.map(r => r.operationName), ['fetchAll']); + }); + }); + + it('should handle cache misses when bulk fetching', () => { + return client.query({ query: fetchAll }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { tasks: flatTasks }); + return client.query({ query: fetchMany, variables: { ids: ['def456', 'badid'] } }); + }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { tasks: [tasks['def456'], null] }); + assert.deepEqual(requests.map(r => r.operationName), ['fetchAll', 'fetchMany']); + }); + }); + + }); + it('should send operationName along with the mutation to the server', (done) => { const mutation = gql` mutation myMutationName { diff --git a/typings/browser/globals/apollo-client/index.d.ts b/typings/browser/globals/apollo-client/index.d.ts index a3330da578b..046c850aa6c 100644 --- a/typings/browser/globals/apollo-client/index.d.ts +++ b/typings/browser/globals/apollo-client/index.d.ts @@ -90,6 +90,11 @@ declare module 'lodash.identity' { export = main.identity; } +declare module 'lodash.every' { + import main = require('~lodash/index'); + export = main.every; +} + /* GRAPHQL diff --git a/typings/main/globals/apollo-client/index.d.ts b/typings/main/globals/apollo-client/index.d.ts index cb9124f4f15..bfc101a4931 100644 --- a/typings/main/globals/apollo-client/index.d.ts +++ b/typings/main/globals/apollo-client/index.d.ts @@ -95,6 +95,11 @@ declare module 'lodash.identity' { export = main.identity; } +declare module 'lodash.every' { + import main = require('~lodash/index'); + export = main.every; +} + /* GRAPHQL