From 08e93675af7ce6f290e85502f522124e27419ca3 Mon Sep 17 00:00:00 2001 From: Ian MacLeod Date: Thu, 21 Jul 2016 13:17:06 -0700 Subject: [PATCH] `resultTransformer` and `resultComparator` You can provide a `resultTransformer` when configuring `ApolloClient` to be able to modify query results immediately before they are returned to the application. Generally, transformers should avoid mutations of their input, as it will bust Apollo's result equality comparsion (and cause unnecessary re-renders). For cases where it is preferable to mutate the input, you can also override how query results are compared for equality. As an example, an application may wish to attach prototypes to result data (for read-only helpers) - it would need to modify the equality check to deep compare the two results while ignoring prototypes. If the newly returned result (without prototypes) matches the data present in the previous result (with prototypes), Apollo would consider it a no-op and not trigger any changes. --- CHANGELOG.md | 2 + src/QueryManager.ts | 40 ++++++++++- src/index.ts | 12 ++++ test/QueryManager.ts | 154 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 535cc80a9c3..c1298820312 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 51bf6b28adf..a936c32221e 100644 --- a/src/QueryManager.ts +++ b/src/QueryManager.ts @@ -85,6 +85,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; @@ -93,6 +109,8 @@ export class QueryManager { private store: ApolloStore; private reduxRootKey: string; private queryTransformer: QueryTransformer; + 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. @@ -125,6 +143,8 @@ export class QueryManager { store, reduxRootKey, queryTransformer, + resultTransformer, + resultComparator, shouldBatch = false, batchInterval = 10, }: { @@ -132,6 +152,8 @@ export class QueryManager { store: ApolloStore, reduxRootKey: string, queryTransformer?: QueryTransformer, + resultTransformer?: ResultTransformer, + resultComparator?: ResultComparator, shouldBatch?: Boolean, batchInterval?: number, }) { @@ -141,6 +163,8 @@ export class QueryManager { this.store = store; this.reduxRootKey = reduxRootKey; this.queryTransformer = queryTransformer; + this.resultTransformer = resultTransformer; + this.resultComparator = resultComparator; this.pollingTimers = {}; this.batchInterval = batchInterval; this.queryListeners = {}; @@ -256,7 +280,7 @@ export class QueryManager { ], }); - resolve(result); + resolve(this.transformResult(result)); }) .catch((err) => { this.store.dispatch({ @@ -318,7 +342,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) { @@ -757,7 +781,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 577eaf90c67..3b9845e5b7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,8 @@ import { import { QueryManager, + ResultComparator, + ResultTransformer, } from './QueryManager'; import { @@ -165,6 +167,8 @@ export default class ApolloClient { public queryManager: QueryManager; public reducerConfig: ApolloReducerConfig; public queryTransformer: QueryTransformer; + public resultTransformer: ResultTransformer; + public resultComparator: ResultComparator; public shouldBatch: boolean; public shouldForceFetch: boolean; public dataId: IdGetter; @@ -177,6 +181,8 @@ export default class ApolloClient { initialState, dataIdFromObject, queryTransformer, + resultTransformer, + resultComparator, shouldBatch = false, ssrMode = false, ssrForceFetchDelay = 0, @@ -188,6 +194,8 @@ export default class ApolloClient { initialState?: any, dataIdFromObject?: IdGetter, queryTransformer?: QueryTransformer, + resultTransformer?: ResultTransformer, + resultComparator?: ResultComparator, shouldBatch?: boolean, ssrMode?: boolean, ssrForceFetchDelay?: number @@ -199,6 +207,8 @@ export default class ApolloClient { this.networkInterface = networkInterface ? networkInterface : createNetworkInterface('/graphql'); this.queryTransformer = queryTransformer; + this.resultTransformer = resultTransformer; + this.resultComparator = resultComparator; this.shouldBatch = shouldBatch; this.shouldForceFetch = !(ssrMode || ssrForceFetchDelay > 0); this.dataId = dataIdFromObject; @@ -304,6 +314,8 @@ export default class ApolloClient { reduxRootKey: this.reduxRootKey, store, queryTransformer: this.queryTransformer, + 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(