diff --git a/CHANGELOG.md b/CHANGELOG.md index de95591b095..4445dd65a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Expect active development and potentially significant breaking changes in the `0 - Add `useAfter` function that accepts `afterwares`. Afterwares run after a request is made (after middlewares). In the afterware function, you get the whole response and request options, so you can handle status codes and errors if you need to. For example, if your requests return a `401` in the case of user logout, you can use this to identify when that starts happening. It can be used just as a `middleware` is used. Just pass an array of afterwares to the `useAfter` function. - Add a stack trace to `ApolloError`. [PR #445](https://github.com/apollostack/apollo-client/pull/445) and [Issue #434](https://github.com/apollostack/apollo-client/issues/434). - Fixed an extra log of errors on `query` calls. [PR #445](https://github.com/apollostack/apollo-client/pull/445) and [Issue #423](https://github.com/apollostack/apollo-client/issues/423). +- 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 bab74813fd9..71a65bb8d5e 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 fffe46eb256..eb6de8ad87c 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 = {}; @@ -299,6 +307,7 @@ export class QueryManager { context: { store: this.getDataWithOptimisticResults(), fragmentMap: queryStoreValue.fragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, rootId: queryStoreValue.query.id, selectionSet: queryStoreValue.query.selectionSet, @@ -615,6 +624,7 @@ export class QueryManager { context: { store: this.store.getState()[this.reduxRootKey].data, fragmentMap: queryFragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, selectionSet: querySS.selectionSet, throwOnMissingField: false, @@ -717,6 +727,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 d799d41e776..fb8390c4c90 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'; @@ -48,6 +53,7 @@ export interface DiffResult { export interface StoreContext { store: NormalizedCache; fragmentMap: FragmentMap; + fetchMiddleware?: StoreFetchMiddleware; } export function diffQueryAgainstStore({ @@ -251,7 +257,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?`); @@ -262,8 +278,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 cec63833005..34ce8728ed3 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'; @@ -805,6 +807,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 +}