diff --git a/CHANGELOG.md b/CHANGELOG.md index e7346fab507..8ea1d6e2d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Expect active development and potentially significant breaking changes in the `0.x` track. We'll try to be diligent about releasing a `1.0` version in a timely fashion (ideally within 3 to 6 months), to signal the start of a more stable API. ### vNEXT +- Implement cache redirects with custom resolvers [PR #921](https://github.com/apollostack/apollo-client/pull/921) ### 0.5.4 - Fix a bug that caused apollo-client to catch errors thrown in Observer.next callbacks [PR #910](https://github.com/apollostack/apollo-client/pull/910) diff --git a/src/ApolloClient.ts b/src/ApolloClient.ts index 47f7075ea76..d33ce08c12e 100644 --- a/src/ApolloClient.ts +++ b/src/ApolloClient.ts @@ -25,6 +25,10 @@ import { Store, } from './store'; +import { + CustomResolverMap, +} from './data/readFromStore'; + import { QueryManager, ApolloQueryResult, @@ -145,6 +149,7 @@ export default class ApolloClient { mutationBehaviorReducers = {} as MutationBehaviorReducerMap, addTypename = true, queryTransformer, + customResolvers, }: { networkInterface?: NetworkInterface, reduxRootKey?: string, @@ -158,6 +163,7 @@ export default class ApolloClient { mutationBehaviorReducers?: MutationBehaviorReducerMap, addTypename?: boolean, queryTransformer?: any, + customResolvers?: CustomResolverMap, } = {}) { if (reduxRootKey && reduxRootSelector) { throw new Error('Both "reduxRootKey" and "reduxRootSelector" are configured, but only one of two is allowed.'); @@ -206,6 +212,7 @@ export default class ApolloClient { this.reducerConfig = { dataIdFromObject, mutationBehaviorReducers, + customResolvers, }; this.watchQuery = this.watchQuery.bind(this); diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 66c1a81cb28..7482467a085 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -141,6 +141,7 @@ export class QueryManager { private resultTransformer: ResultTransformer; private resultComparator: ResultComparator; private reducerConfig: ApolloReducerConfig; + // TODO REFACTOR collect all operation-related info in one place (e.g. all these maps) // this should be combined with ObservableQuery, but that needs to be expanded to support // mutations and subscriptions as well. @@ -381,6 +382,7 @@ export class QueryManager { query: this.queryDocuments[queryId], variables: queryStoreValue.previousVariables || queryStoreValue.variables, returnPartialData: options.returnPartialData || options.noFetch, + config: this.reducerConfig, }), loading: queryStoreValue.loading, networkStatus: queryStoreValue.networkStatus, @@ -479,6 +481,7 @@ export class QueryManager { store: this.reduxRootSelector(this.store.getState()).data, returnPartialData: true, variables, + config: this.reducerConfig, }); // If we're in here, only fetch if we have missing fields @@ -733,6 +736,7 @@ export class QueryManager { query: document, variables, returnPartialData: false, + config: this.reducerConfig, }; try { @@ -951,6 +955,7 @@ export class QueryManager { variables, returnPartialData: returnPartialData || noFetch, query: document, + config: this.reducerConfig, }); // ensure multiple errors don't get thrown /* tslint:disable */ diff --git a/src/data/readFromStore.ts b/src/data/readFromStore.ts index 9cb4a3dfb56..5d65cb73650 100644 --- a/src/data/readFromStore.ts +++ b/src/data/readFromStore.ts @@ -22,6 +22,10 @@ import { getQueryDefinition, } from '../queries/getFromAST'; +import { + ApolloReducerConfig, +} from '../store'; + export type DiffResult = { result?: any; isMissing?: boolean; @@ -32,8 +36,17 @@ export type ReadQueryOptions = { query: Document, variables?: Object, returnPartialData?: boolean, + config?: ApolloReducerConfig, } +export type CustomResolver = (rootValue: any, args: { [argName: string]: any }) => any; + +export type CustomResolverMap = { + [typeName: string]: { + [fieldName: string]: CustomResolver + } +}; + /** * Resolves the result of a query solely from the store (i.e. never hits the server). * @@ -54,12 +67,14 @@ export function readQueryFromStore({ query, variables, returnPartialData = false, + config, }: ReadQueryOptions): Object { const { result } = diffQueryAgainstStore({ query, store, returnPartialData, variables, + config, }); return result; @@ -69,6 +84,7 @@ type ReadStoreContext = { store: NormalizedCache; returnPartialData: boolean; hasMissingField: boolean; + customResolvers: CustomResolverMap; } let haveWarned = false; @@ -130,6 +146,20 @@ const readStoreResolver: Resolver = ( const fieldValue = (obj || {})[storeKeyName]; if (typeof fieldValue === 'undefined') { + if (context.customResolvers && obj && (obj.__typename || objId === 'ROOT_QUERY')) { + const typename = obj.__typename || 'Query'; + + // Look for the type in the custom resolver map + const type = context.customResolvers[typename]; + if (type) { + // Look for the field in the custom resolver map + const resolver = type[fieldName]; + if (resolver) { + return resolver(obj, args); + } + } + } + if (! context.returnPartialData) { throw new Error(`Can't find field ${storeKeyName} on object (${objId}) ${JSON.stringify(obj, null, 2)}. Perhaps you want to use the \`returnPartialData\` option?`); @@ -161,15 +191,18 @@ export function diffQueryAgainstStore({ query, variables, returnPartialData = true, + config, }: ReadQueryOptions): DiffResult { // Throw the right validation error by trying to find a query in the document getQueryDefinition(query); const context: ReadStoreContext = { + // Global settings store, returnPartialData, + customResolvers: config && config.customResolvers, - // Filled in during execution + // Flag set during execution hasMissingField: false, }; diff --git a/src/data/resultReducers.ts b/src/data/resultReducers.ts index e56efda5510..4f85eeaa14a 100644 --- a/src/data/resultReducers.ts +++ b/src/data/resultReducers.ts @@ -46,9 +46,12 @@ export function createStoreReducer( query: document, variables, returnPartialData: true, + config, }); // TODO add info about networkStatus + const nextResult = resultReducer(currentResult, action); // action should include operation name + if (currentResult !== nextResult) { return writeResultToStore({ dataId: 'ROOT_QUERY', diff --git a/src/data/storeUtils.ts b/src/data/storeUtils.ts index 6757513c993..27752318737 100644 --- a/src/data/storeUtils.ts +++ b/src/data/storeUtils.ts @@ -149,6 +149,14 @@ export function isIdValue(idObject: StoreValue): idObject is IdValue { return (isObject(idObject) && (idObject as (IdValue | JsonValue)).type === 'id'); } +export function toIdValue(id: string, generated = false): IdValue { + return { + type: 'id', + id, + generated, + }; +} + export function isJsonValue(jsonObject: StoreValue): jsonObject is JsonValue { return (isObject(jsonObject) && (jsonObject as (IdValue | JsonValue)).type === 'json'); } diff --git a/src/index.ts b/src/index.ts index e5747f8504d..186dd7dbfd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,10 @@ import { ApolloQueryResult, } from './core/QueryManager'; +import { + toIdValue, +} from './data/storeUtils'; + // We expose the print method from GraphQL so that people that implement // custom network interfaces can turn query ASTs into query strings as needed. export { @@ -94,6 +98,8 @@ export { ApolloQueryResult, + toIdValue, + // internal type definitions for export WatchQueryOptions, MutationOptions, diff --git a/src/store.ts b/src/store.ts index e830c532f69..43094cba27a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -40,6 +40,10 @@ import { MutationBehaviorReducerMap, } from './data/mutationResults'; +import { + CustomResolverMap, +} from './data/readFromStore'; + import assign = require('lodash.assign'); export interface Store { @@ -151,9 +155,10 @@ export function createApolloStore({ } -export interface ApolloReducerConfig { +export type ApolloReducerConfig = { dataIdFromObject?: IdGetter; mutationBehaviorReducers?: MutationBehaviorReducerMap; + customResolvers?: CustomResolverMap; } export function getDataWithOptimisticResults(store: Store): NormalizedCache { diff --git a/test/QueryManager.ts b/test/QueryManager.ts index 8f957ebbc80..aaafd4c37be 100644 --- a/test/QueryManager.ts +++ b/test/QueryManager.ts @@ -3166,5 +3166,4 @@ describe('QueryManager', () => { // We have an unhandled error warning from the `subscribe` above, which has no `error` cb }); - }); diff --git a/test/customResolvers.ts b/test/customResolvers.ts new file mode 100644 index 00000000000..5364940ad58 --- /dev/null +++ b/test/customResolvers.ts @@ -0,0 +1,62 @@ +import mockNetworkInterface from './mocks/mockNetworkInterface'; +import gql from 'graphql-tag'; +import { assert } from 'chai'; +import ApolloClient, { toIdValue } from '../src'; + +import { NetworkStatus } from '../src/queries/store'; + +describe('custom resolvers', () => { + it(`works for cache redirection`, () => { + const dataIdFromObject = (obj: any) => { + return obj.id; + }; + + const listQuery = gql`{ people { id name } }`; + + const listData = { + people: [ + { + id: '4', + name: 'Luke Skywalker', + __typename: 'Person', + }, + ], + }; + + const netListQuery = gql`{ people { id name __typename } }`; + + const itemQuery = gql`{ person(id: 4) { id name } }`; + + // We don't expect the item query to go to the server at all + const networkInterface = mockNetworkInterface({ + request: { query: netListQuery }, + result: { data: listData }, + }); + + const client = new ApolloClient({ + networkInterface, + customResolvers: { + Query: { + person: (_, args) => toIdValue(args['id']), + }, + }, + dataIdFromObject, + }); + + return client.query({ query: listQuery }).then(() => { + return client.query({ query: itemQuery }); + }).then((itemResult) => { + assert.deepEqual(itemResult, { + loading: false, + networkStatus: NetworkStatus.ready, + data: { + person: { + __typename: 'Person', + id: '4', + name: 'Luke Skywalker', + }, + }, + }); + }); + }); +}); diff --git a/test/readFromStore.ts b/test/readFromStore.ts index 7f5139687ae..084dc751878 100644 --- a/test/readFromStore.ts +++ b/test/readFromStore.ts @@ -569,4 +569,81 @@ describe('reading from the store', () => { simpleArray: [null, 'two', 'three'], }); }); + + it('runs a query with custom resolvers for a computed field', () => { + const result = { + __typename: 'Thing', + id: 'abcd', + stringField: 'This is a string!', + numberField: 5, + nullField: null, + } as StoreObject; + + const store = { + 'ROOT_QUERY': result, + } as NormalizedCache; + + const queryResult = readQueryFromStore({ + store, + query: gql` + query { + stringField + numberField + computedField(extra: "bit") @client + } + `, + config: { + customResolvers: { + Thing: { + computedField: (obj, args) => obj.stringField + obj.numberField + args['extra'], + }, + }, + }, + }); + + // The result of the query shouldn't contain __data_id fields + assert.deepEqual(queryResult, { + stringField: result['stringField'], + numberField: result['numberField'], + computedField: 'This is a string!5bit', + }); + }); + + it('runs a query with custom resolvers for a computed field on root Query', () => { + const result = { + id: 'abcd', + stringField: 'This is a string!', + numberField: 5, + nullField: null, + } as StoreObject; + + const store = { + 'ROOT_QUERY': result, + } as NormalizedCache; + + const queryResult = readQueryFromStore({ + store, + query: gql` + query { + stringField + numberField + computedField(extra: "bit") @client + } + `, + config: { + customResolvers: { + Query: { + computedField: (obj, args) => obj.stringField + obj.numberField + args['extra'], + }, + }, + }, + }); + + // The result of the query shouldn't contain __data_id fields + assert.deepEqual(queryResult, { + stringField: result['stringField'], + numberField: result['numberField'], + computedField: 'This is a string!5bit', + }); + }); }); diff --git a/test/tests.ts b/test/tests.ts index b189e61ebe6..aa4485df78e 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -48,3 +48,4 @@ import './graphqlSubscriptions'; import './batchedNetworkInterface'; import './ObservableQuery'; import './subscribeToMore'; +import './customResolvers';