diff --git a/CHANGELOG.md b/CHANGELOG.md index fad2aa46397..d5ac8250adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Expect active development and potentially significant breaking changes in the `0 ### vNEXT - 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 96a820bca21..c3e2878b215 100644 --- a/ambient.d.ts +++ b/ambient.d.ts @@ -88,6 +88,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 35de08bed92..1478e2782d2 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 057f29e9fad..f11fb92fcd5 100644 --- a/src/QueryManager.ts +++ b/src/QueryManager.ts @@ -48,6 +48,10 @@ import { diffSelectionSetAgainstStore, } from './data/diffAgainstStore'; +import { + StoreFetchMiddleware, +} from './data/fetchMiddleware'; + import { MutationBehavior, } from './data/mutationResults'; @@ -113,6 +117,7 @@ export class QueryManager { private reduxRootKey: string; private pollingTimers: {[queryId: string]: NodeJS.Timer | any}; //oddity in Typescript private queryTransformer: QueryTransformer; + private storeFetchMiddleware: StoreFetchMiddleware; private queryListeners: { [queryId: string]: QueryListener }; private idCounter = 0; @@ -144,12 +149,14 @@ export class QueryManager { store, reduxRootKey, queryTransformer, + storeFetchMiddleware, shouldBatch = false, }: { networkInterface: NetworkInterface, store: ApolloStore, reduxRootKey: string, queryTransformer?: QueryTransformer, + storeFetchMiddleware?: StoreFetchMiddleware, shouldBatch?: Boolean, }) { // XXX this might be the place to do introspection for inserting the `id` into the query? or @@ -158,6 +165,7 @@ export class QueryManager { this.store = store; this.reduxRootKey = reduxRootKey; this.queryTransformer = queryTransformer; + this.storeFetchMiddleware = storeFetchMiddleware; this.pollingTimers = {}; this.queryListeners = {}; @@ -283,6 +291,7 @@ export class QueryManager { context: { store: this.getApolloState().data, fragmentMap: queryStoreValue.fragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, rootId: queryStoreValue.query.id, selectionSet: queryStoreValue.query.selectionSet, @@ -377,6 +386,7 @@ export class QueryManager { context: { store: this.getApolloState().data, fragmentMap: queryStoreValue.fragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, rootId: queryStoreValue.query.id, selectionSet: queryStoreValue.query.selectionSet, @@ -564,6 +574,7 @@ export class QueryManager { context: { store: this.store.getState()[this.reduxRootKey].data, fragmentMap: queryFragmentMap, + fetchMiddleware: this.storeFetchMiddleware, }, selectionSet: querySS.selectionSet, throwOnMissingField: false, @@ -666,6 +677,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 d9544b33c0b..0bc43a51e98 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; + // Give the transformer a chance to yield a rewritten result. + if (context.fetchMiddleware) { + storeValue = context.fetchMiddleware(field, variables, context.store, () => storeObj[storeFieldKey]); + } else { + storeValue = storeObj[storeFieldKey]; + } + + // This may seem crazy, but we care about the difference between the cases + // where a value is undefined vs when it is not present in the store. + if (isUndefined(storeValue) && !has(storeObj, storeFieldKey)) { if (throwOnMissingField && included) { throw new Error(`Can't find field ${storeFieldKey} on object ${storeObj}.`); } @@ -260,8 +276,6 @@ function diffFieldAgainstStore({ }; } - 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 1c6840a9e0a..1e5176b5df0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,11 @@ import { addTypenameToSelectionSet, } from './queries/queryTransform'; +import { + cachedFetchById, + StoreFetchMiddleware, +} from './data/fetchMiddleware'; + import { MutationBehavior, MutationBehaviorReducerMap, @@ -72,6 +77,7 @@ export { readQueryFromStore, readFragmentFromStore, addTypenameToSelectionSet as addTypename, + cachedFetchById, writeQueryToStore, writeFragmentToStore, print as printAST, @@ -141,6 +147,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; @@ -152,6 +159,7 @@ export default class ApolloClient { initialState, dataIdFromObject, queryTransformer, + storeFetchMiddleware, shouldBatch = false, ssrMode = false, ssrForceFetchDelay = 0, @@ -162,6 +170,7 @@ export default class ApolloClient { initialState?: any, dataIdFromObject?: IdGetter, queryTransformer?: QueryTransformer, + storeFetchMiddleware?: StoreFetchMiddleware, shouldBatch?: boolean, ssrMode?: boolean, ssrForceFetchDelay?: number @@ -172,6 +181,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; @@ -274,6 +284,7 @@ export default class ApolloClient { reduxRootKey: this.reduxRootKey, store, queryTransformer: this.queryTransformer, + storeFetchMiddleware: this.storeFetchMiddleware, shouldBatch: this.shouldBatch, }); }; diff --git a/test/client.ts b/test/client.ts index 02957a76cb9..9a778da0838 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'; @@ -795,6 +797,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 7e5babf3736..861b8c1be6e 100644 --- a/typings/browser/globals/apollo-client/index.d.ts +++ b/typings/browser/globals/apollo-client/index.d.ts @@ -85,6 +85,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 7e5babf3736..861b8c1be6e 100644 --- a/typings/main/globals/apollo-client/index.d.ts +++ b/typings/main/globals/apollo-client/index.d.ts @@ -85,6 +85,11 @@ declare module 'lodash.identity' { export = main.identity; } +declare module 'lodash.every' { + import main = require('~lodash/index'); + export = main.every; +} + /* GRAPHQL