From a31251d3863319d2131a2271b6ac2e3a309671cc Mon Sep 17 00:00:00 2001 From: Ian MacLeod Date: Sun, 10 Jul 2016 12:16:51 -0700 Subject: [PATCH] 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 | 70 ++++++++++ src/index.ts | 11 ++ test/client.ts | 128 ++++++++++++++++++ .../browser/globals/apollo-client/index.d.ts | 7 +- typings/main/globals/apollo-client/index.d.ts | 7 +- 10 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 src/data/fetchMiddleware.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 535cc80a9c3..4d53a6cd0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Expect active development and potentially significant breaking changes in the `0 - Fix repeat calls to a query that includes fragments [PR #447](https://github.com/apollostack/apollo-client/pull/447). - GraphQL errors on mutation results now result in a rejected promise and are no longer a part of returned results. [PR #465](https://github.com/apollostack/apollo-client/pull/465) and [Issue #458](https://github.com/apollostack/apollo-client/issues/458). - Don't add fields to root mutations and root queries [PR #463](https://github.com/apollostack/apollo-client/pull/463) and [Issue #413](https://github.com/apollostack/apollo-client/issues/413). +- 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.4.7 diff --git a/ambient.d.ts b/ambient.d.ts index 47ef18ba9e5..0a64672fba3 100644 --- a/ambient.d.ts +++ b/ambient.d.ts @@ -103,6 +103,11 @@ declare module 'lodash.pick' { export = main.pick; } +declare module 'lodash.every' { + import main = require('~lodash/index'); + export = main.every; +} + /* GRAPHQL diff --git a/package.json b/package.json index 885bb5e31bd..42858f938f2 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.flatten": "^4.2.0", "lodash.forown": "^4.1.0", "lodash.has": "^4.3.1", diff --git a/src/QueryManager.ts b/src/QueryManager.ts index 55929a33e71..3ce526d5e84 100644 --- a/src/QueryManager.ts +++ b/src/QueryManager.ts @@ -51,6 +51,10 @@ import { diffSelectionSetAgainstStore, } from './data/diffAgainstStore'; +import { + StoreFetchMiddleware, +} from './data/fetchMiddleware'; + import { MutationBehavior, MutationQueryReducersMap, @@ -93,6 +97,7 @@ export class QueryManager { private store: ApolloStore; private reduxRootKey: string; private queryTransformer: QueryTransformer; + private storeFetchMiddleware: StoreFetchMiddleware; private queryListeners: { [queryId: string]: QueryListener }; // A map going from queryId to the last result/state that the queryListener was told about. @@ -125,6 +130,7 @@ export class QueryManager { store, reduxRootKey, queryTransformer, + storeFetchMiddleware, shouldBatch = false, batchInterval = 10, }: { @@ -132,6 +138,7 @@ export class QueryManager { store: ApolloStore, reduxRootKey: string, queryTransformer?: QueryTransformer, + storeFetchMiddleware?: StoreFetchMiddleware, shouldBatch?: Boolean, batchInterval?: number, }) { @@ -141,6 +148,7 @@ export class QueryManager { this.store = store; this.reduxRootKey = reduxRootKey; this.queryTransformer = queryTransformer; + this.storeFetchMiddleware = storeFetchMiddleware; this.pollingTimers = {}; this.batchInterval = batchInterval; this.queryListeners = {}; @@ -309,6 +317,7 @@ export class QueryManager { context: { store: this.getDataWithOptimisticResults(), fragmentMap: queryStoreValue.fragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, rootId: queryStoreValue.query.id, selectionSet: queryStoreValue.query.selectionSet, @@ -625,6 +634,7 @@ export class QueryManager { context: { store: this.store.getState()[this.reduxRootKey].data, fragmentMap: queryFragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, selectionSet: querySS.selectionSet, throwOnMissingField: false, @@ -727,6 +737,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 04855374490..2ad65f66248 100644 --- a/src/data/diffAgainstStore.ts +++ b/src/data/diffAgainstStore.ts @@ -1,5 +1,6 @@ import isArray = require('lodash.isarray'); import isNull = require('lodash.isnull'); +import isUndefined = require('lodash.isundefined'); import has = require('lodash.has'); import assign = require('lodash.assign'); @@ -16,6 +17,10 @@ import { isIdValue, } from './store'; +import { + StoreFetchMiddleware, +} from './fetchMiddleware'; + import { SelectionSetWithRoot, } from '../queries/store'; @@ -52,6 +57,7 @@ export interface DiffResult { export interface StoreContext { store: NormalizedCache; fragmentMap: FragmentMap; + fetchMiddleware?: StoreFetchMiddleware; } export function diffQueryAgainstStore({ @@ -321,7 +327,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 ApolloError({ errorMessage: `Can't find field ${storeFieldKey} on object ${JSON.stringify(storeObj)}. @@ -337,8 +353,6 @@ Perhaps you want to use the \`returnPartialData\` option?`, }; } - const storeValue = storeObj[storeFieldKey]; - // Handle all scalar types here if (! field.selectionSet) { if (isJsonValue(storeValue)) { diff --git a/src/data/fetchMiddleware.ts b/src/data/fetchMiddleware.ts new file mode 100644 index 00000000000..08442d62149 --- /dev/null +++ b/src/data/fetchMiddleware.ts @@ -0,0 +1,70 @@ +import every = require('lodash.every'); +import has = require('lodash.has'); + +import { + Field, + Variable, +} from 'graphql'; + +import { + NormalizedCache, + StoreValue, + IdValue, +} 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: () => StoreValue +) => StoreValue; + +// 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: () => StoreValue +): StoreValue { + // 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]; + // Only supports variables, for now. + if (onlyArg.value.kind === 'Variable') { + const variable = onlyArg.value; + if (onlyArg.name.value === 'id') { + const id = variables[variable.name.value]; + if (has(store, id)) { + return toIdValue(id); + } + } else if (onlyArg.name.value === 'ids') { + const ids = variables[variable.name.value]; + if (every(ids, id => has(store, id))) { + return ids; + } + } + } + } + + // Otherwise, fall back to the regular behavior. + return next(); +} + +function toIdValue(id): IdValue { + return { + type: 'id', + id, + generated: false, + }; +} diff --git a/src/index.ts b/src/index.ts index 577eaf90c67..badf1fbf314 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,11 @@ import { addTypenameToSelectionSet, } from './queries/queryTransform'; +import { + cachedFetchById, + StoreFetchMiddleware, +} from './data/fetchMiddleware'; + import { MutationBehavior, MutationBehaviorReducerMap, @@ -87,6 +92,7 @@ export { readQueryFromStore, readFragmentFromStore, addTypenameToSelectionSet as addTypename, + cachedFetchById, writeQueryToStore, writeFragmentToStore, print as printAST, @@ -165,6 +171,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; @@ -177,6 +184,7 @@ export default class ApolloClient { initialState, dataIdFromObject, queryTransformer, + storeFetchMiddleware, shouldBatch = false, ssrMode = false, ssrForceFetchDelay = 0, @@ -188,6 +196,7 @@ export default class ApolloClient { initialState?: any, dataIdFromObject?: IdGetter, queryTransformer?: QueryTransformer, + storeFetchMiddleware?: StoreFetchMiddleware, shouldBatch?: boolean, ssrMode?: boolean, ssrForceFetchDelay?: number @@ -199,6 +208,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; @@ -304,6 +314,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 06468efc718..8d1109f842a 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($taskId: ID!) { + task(id: $taskId) { + id + name + } + } + `; + fetchMany = gql` + query fetchMany($taskIds: [ID]!) { + tasks(ids: $taskIds) { + 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['taskIds']; + resolve({ data: { tasks: ids.map(i => tasks[i] || null) } }); + } else if (request.operationName === 'fetchOne') { + resolve({ data: { task: tasks[request.variables['taskId']] || 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: { taskId: '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: { taskId: 'abc123' } }) + .then((actualResult) => { + assert.deepEqual(actualResult.data, { task: tasks['abc123'] }); + return client.query({ query: fetchOne, variables: { taskId: '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: { taskId: '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: { taskId: '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: { taskIds: ['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: { taskIds: ['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 398410c95fd..238f3c2f2b8 100644 --- a/typings/browser/globals/apollo-client/index.d.ts +++ b/typings/browser/globals/apollo-client/index.d.ts @@ -100,6 +100,11 @@ declare module 'lodash.pick' { export = main.pick; } +declare module 'lodash.every' { + import main = require('~lodash/index'); + export = main.every; +} + /* GRAPHQL @@ -116,4 +121,4 @@ declare module 'graphql-tag/parser' { declare module 'graphql-tag/printer' { function print(ast: any): string; -} \ No newline at end of file +} diff --git a/typings/main/globals/apollo-client/index.d.ts b/typings/main/globals/apollo-client/index.d.ts index 398410c95fd..238f3c2f2b8 100644 --- a/typings/main/globals/apollo-client/index.d.ts +++ b/typings/main/globals/apollo-client/index.d.ts @@ -100,6 +100,11 @@ declare module 'lodash.pick' { export = main.pick; } +declare module 'lodash.every' { + import main = require('~lodash/index'); + export = main.every; +} + /* GRAPHQL @@ -116,4 +121,4 @@ declare module 'graphql-tag/parser' { declare module 'graphql-tag/printer' { function print(ast: any): string; -} \ No newline at end of file +}