diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d53a6cd0c7..813ec62506c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Expect active development and potentially significant breaking changes in the `0 ### vNEXT +- Added `resultTransformer` and `resultComparator` to `ApolloClient`/`QueryManager`, which afford the ability to transform result objects immediately before they are returned to the application. [PR #446](https://github.com/apollostack/apollo-client/pull/446) + ### v0.4.8 - 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. diff --git a/src/QueryManager.ts b/src/QueryManager.ts index 3ce526d5e84..cfb02d08bfa 100644 --- a/src/QueryManager.ts +++ b/src/QueryManager.ts @@ -89,6 +89,22 @@ import { ObservableQuery } from './ObservableQuery'; export type QueryListener = (queryStoreValue: QueryStoreValue) => void; +// A result transformer is given the data that is to be returned from the store from a query or +// mutation, and can modify or observe it before the value is provided to your application. +// +// For watched queries, the transformer is only called when the data retrieved from the server is +// different from previous. +// +// If the transformer wants to mutate results (say, by setting the prototype of result data), it +// will likely need to be paired with a custom resultComparator. By default, Apollo performs a +// deep equality comparsion on results, and skips those that are considered equal - reducing +// re-renders. +export type ResultTransformer = (resultData: ApolloQueryResult) => ApolloQueryResult; + +// Controls how Apollo compares two query results and considers their equality. Two equal results +// will not trigger re-renders. +export type ResultComparator = (result1: ApolloQueryResult, result2: ApolloQueryResult) => boolean; + export class QueryManager { public pollingTimers: {[queryId: string]: NodeJS.Timer | any}; //oddity in Typescript public scheduler: QueryScheduler; @@ -98,6 +114,8 @@ export class QueryManager { private reduxRootKey: string; private queryTransformer: QueryTransformer; private storeFetchMiddleware: StoreFetchMiddleware; + private resultTransformer: ResultTransformer; + private resultComparator: ResultComparator; private queryListeners: { [queryId: string]: QueryListener }; // A map going from queryId to the last result/state that the queryListener was told about. @@ -131,6 +149,8 @@ export class QueryManager { reduxRootKey, queryTransformer, storeFetchMiddleware, + resultTransformer, + resultComparator, shouldBatch = false, batchInterval = 10, }: { @@ -139,6 +159,8 @@ export class QueryManager { reduxRootKey: string, queryTransformer?: QueryTransformer, storeFetchMiddleware?: StoreFetchMiddleware, + resultTransformer?: ResultTransformer, + resultComparator?: ResultComparator, shouldBatch?: Boolean, batchInterval?: number, }) { @@ -149,6 +171,8 @@ export class QueryManager { this.reduxRootKey = reduxRootKey; this.queryTransformer = queryTransformer; this.storeFetchMiddleware = storeFetchMiddleware; + this.resultTransformer = resultTransformer; + this.resultComparator = resultComparator; this.pollingTimers = {}; this.batchInterval = batchInterval; this.queryListeners = {}; @@ -264,7 +288,7 @@ export class QueryManager { ], }); - resolve(result); + resolve(this.transformResult(result)); }) .catch((err) => { this.store.dispatch({ @@ -329,7 +353,7 @@ export class QueryManager { if (observer.next) { if (this.isDifferentResult(queryId, resultFromStore )) { this.queryResults[queryId] = resultFromStore; - observer.next(resultFromStore); + observer.next(this.transformResult(resultFromStore)); } } } catch (error) { @@ -776,7 +800,17 @@ export class QueryManager { // Given a query id and a new result, this checks if the old result is // the same as the last result for that particular query id. private isDifferentResult(queryId: string, result: ApolloQueryResult): boolean { - return !isEqual(this.queryResults[queryId], result); + const comparator = this.resultComparator || isEqual; + return !comparator(this.queryResults[queryId], result); + } + + // Give the result transformer a chance to observe or modify result data before it is passed on. + private transformResult(result: ApolloQueryResult): ApolloQueryResult { + if (!this.resultTransformer) { + return result; + } else { + return this.resultTransformer(result); + } } private broadcastQueries() { diff --git a/src/index.ts b/src/index.ts index badf1fbf314..a7c930f4da8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,8 @@ import { import { QueryManager, + ResultComparator, + ResultTransformer, } from './QueryManager'; import { @@ -172,6 +174,8 @@ export default class ApolloClient { public reducerConfig: ApolloReducerConfig; public queryTransformer: QueryTransformer; public storeFetchMiddleware: StoreFetchMiddleware; + public resultTransformer: ResultTransformer; + public resultComparator: ResultComparator; public shouldBatch: boolean; public shouldForceFetch: boolean; public dataId: IdGetter; @@ -185,6 +189,8 @@ export default class ApolloClient { dataIdFromObject, queryTransformer, storeFetchMiddleware, + resultTransformer, + resultComparator, shouldBatch = false, ssrMode = false, ssrForceFetchDelay = 0, @@ -197,6 +203,8 @@ export default class ApolloClient { dataIdFromObject?: IdGetter, queryTransformer?: QueryTransformer, storeFetchMiddleware?: StoreFetchMiddleware, + resultTransformer?: ResultTransformer, + resultComparator?: ResultComparator, shouldBatch?: boolean, ssrMode?: boolean, ssrForceFetchDelay?: number @@ -209,6 +217,8 @@ export default class ApolloClient { createNetworkInterface('/graphql'); this.queryTransformer = queryTransformer; this.storeFetchMiddleware = storeFetchMiddleware; + this.resultTransformer = resultTransformer; + this.resultComparator = resultComparator; this.shouldBatch = shouldBatch; this.shouldForceFetch = !(ssrMode || ssrForceFetchDelay > 0); this.dataId = dataIdFromObject; @@ -315,6 +325,8 @@ export default class ApolloClient { store, queryTransformer: this.queryTransformer, storeFetchMiddleware: this.storeFetchMiddleware, + resultTransformer: this.resultTransformer, + resultComparator: this.resultComparator, shouldBatch: this.shouldBatch, batchInterval: this.batchInterval, }); diff --git a/test/QueryManager.ts b/test/QueryManager.ts index 83ec1bffc12..dbae5ec8147 100644 --- a/test/QueryManager.ts +++ b/test/QueryManager.ts @@ -3170,6 +3170,160 @@ describe('QueryManager', () => { done(); }, 120); }); + + describe('result transformation', () => { + + let client: ApolloClient, response, transformCount; + beforeEach(() => { + transformCount = 0; + + const networkInterface: NetworkInterface = { + query(request: Request): Promise { + return Promise.resolve(response); + }, + }; + + client = new ApolloClient({ + networkInterface, + resultTransformer(result) { + transformCount++; + return { + data: assign({}, result.data, {transformCount}), + }; + }, + }); + }); + + it('transforms query() results', () => { + response = {data: {foo: 123}}; + return client.query({query: gql`{ foo }`}) + .then((result) => { + assert.deepEqual(result.data, {foo: 123, transformCount: 1}); + }); + }); + + it('transforms watchQuery() results', (done) => { + response = {data: {foo: 123}}; + const handle = client.watchQuery({query: gql`{ foo }`}); + + let callCount = 0; + handle.subscribe({ + error: done, + next(result) { + try { + callCount++; + if (callCount === 1) { + assert.deepEqual(result.data, {foo: 123, transformCount: 1}); + response = {data: {foo: 456}}; + handle.refetch(); + } else { + assert.deepEqual(result.data, {foo: 456, transformCount: 2}); + done(); + } + } catch (error) { + done(error); + } + }, + }); + }); + + it('does not transform identical watchQuery() results', (done) => { + response = {data: {foo: 123}}; + const handle = client.watchQuery({query: gql`{ foo }`}); + + let callCount = 0; + handle.subscribe({ + error: done, + next(result) { + callCount++; + try { + assert.equal(callCount, 1, 'observer should only fire once'); + assert.deepEqual(result.data, {foo: 123, transformCount: 1}); + } catch (error) { + done(error); + return; + } + + response = {data: {foo: 123}}; // Ensure we have new response objects. + handle.refetch().then(() => { + // Skip done() on subsequent calls, because that means we've already failed. This gives + // us better test failure output. + if (callCount === 1) { + done(); + } + }).catch(done); + }, + }); + }); + + it('transforms mutate() results', () => { + response = {data: {foo: 123}}; + return client.mutate({mutation: gql`mutation makeChanges { foo }`}) + .then((result) => { + assert.deepEqual(result.data, {foo: 123, transformCount: 1}); + }); + }); + + }); + + describe('result transformation with custom equality', () => { + + class Model {} + + let client: ApolloClient, response; + beforeEach(() => { + const networkInterface: NetworkInterface = { + query(request: Request): Promise { + return Promise.resolve(response); + }, + }; + + client = new ApolloClient({ + networkInterface, + resultTransformer(result) { + result.data.__proto__ = Model.prototype; + return result; + }, + resultComparator(result1, result2) { + // A real example would, say, deep compare the two while ignoring prototypes. + const foo1 = result1 && result1.data && result1.data.foo; + const foo2 = result2 && result2.data && result2.data.foo; + return foo1 === foo2; + }, + }); + }); + + it('does not transform identical watchQuery() results, according to the comparator', (done) => { + response = {data: {foo: 123}}; + const handle = client.watchQuery({query: gql`{ foo }`}); + + let callCount = 0; + handle.subscribe({ + error: done, + next(result) { + callCount++; + try { + assert.equal(callCount, 1, 'observer should only fire once'); + assert.instanceOf(result.data, Model); + } catch (error) { + done(error); + return; + } + + response = {data: {foo: 123}}; // Ensure we have new response objects. + handle.refetch().then(() => { + // Skip done() on subsequent calls, because that means we've already failed. This gives + // us better test failure output. + if (callCount === 1) { + done(); + } + }).catch(done); + }, + }); + }); + + }); + }); function testDiffing(