diff --git a/CHANGELOG.md b/CHANGELOG.md index b26c5c8a5f4..84e48495646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Expect active development and potentially significant breaking changes in the `0 ### vNEXT +- Implemented query merging and batching support [Issue #164](https://github.com/apollostack/apollo-client/issues/164), [PR #278](https://github.com/apollostack/apollo-client/pull/278) and [PR #277](https://github.com/apollostack/apollo-client/pull/277) + ### v0.3.15 - Added support for `@skip` and `@include` directives - see [Issue #237](https://github.com/apollostack/apollo-client/issues/237) and [PR #275](https://github.com/apollostack/apollo-client/pull/275) diff --git a/README.md b/README.md index 010a696f9ef..cf0f5083ec4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The Apollo Client can easily be dropped into any JavaScript frontend where you want to use data from a GraphQL server. -It's simple to use, and very small (less than 30kb), while having a lot of useful features around caching, polling, and refetching. +It's simple to use, and very small (less than 32kb), while having a lot of useful features around caching, polling, and refetching. ## Installing diff --git a/package.json b/package.json index 1fa957b3d4d..b24864c0901 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "pretest": "npm run compile", "test": "npm run testonly --", "posttest": "npm run lint", - "filesize": "npm run compile:browser && ./scripts/filesize.js --file=./dist/index.min.js --maxGzip=31", + "filesize": "npm run compile:browser && ./scripts/filesize.js --file=./dist/index.min.js --maxGzip=32", "compile": "tsc", "compile:browser": "rm -rf ./dist && mkdir ./dist && browserify ./lib/src/index.js -o=./dist/index.js && npm run minify:browser", "minify:browser": "uglifyjs --compress --mangle --screw-ie8 -o=./dist/index.min.js -- ./dist/index.js", diff --git a/src/QueryManager.ts b/src/QueryManager.ts index 8c3b92017fa..e7bbc592d76 100644 --- a/src/QueryManager.ts +++ b/src/QueryManager.ts @@ -51,6 +51,15 @@ import { queryDocument, } from './queryPrinting'; +import { + QueryFetchRequest, + QueryBatcher, +} from './batching'; + +import { + QueryScheduler, +} from './scheduler'; + import { Observable, Observer, Subscription } from './util/Observable'; export class ObservableQuery extends Observable { @@ -90,7 +99,7 @@ export interface WatchQueryOptions { pollInterval?: number; } -type QueryListener = (queryStoreValue: QueryStoreValue) => void +export type QueryListener = (queryStoreValue: QueryStoreValue) => void export class QueryManager { private networkInterface: NetworkInterface; @@ -102,16 +111,22 @@ export class QueryManager { private idCounter = 0; + private scheduler: QueryScheduler; + private batcher: QueryBatcher; + private batcherPollInterval = 10; + constructor({ networkInterface, store, reduxRootKey, queryTransformer, + shouldBatch = false, }: { networkInterface: NetworkInterface, store: ApolloStore, reduxRootKey: string, queryTransformer?: QueryTransformer, + shouldBatch?: Boolean, }) { // XXX this might be the place to do introspection for inserting the `id` into the query? or // is that the network interface? @@ -123,6 +138,17 @@ export class QueryManager { this.queryListeners = {}; + this.scheduler = new QueryScheduler({ + queryManager: this, + }); + + this.batcher = new QueryBatcher({ + shouldBatch, + networkInterface: this.networkInterface, + }); + + this.batcher.start(this.batcherPollInterval); + // this.store is usually the fake store we get from the Redux middleware API // XXX for tests, we sometimes pass in a real Redux store into the QueryManager if (this.store['subscribe']) { @@ -193,6 +219,45 @@ export class QueryManager { }); } + // Returns a query listener that will update the given observer based on the + // results (or lack thereof) for a particular query. + public queryListenerForObserver(options: WatchQueryOptions, + observer: Observer): QueryListener { + return (queryStoreValue: QueryStoreValue) => { + if (!queryStoreValue.loading || queryStoreValue.returnPartialData) { + // XXX Currently, returning errors and data is exclusive because we + // don't handle partial results + if (queryStoreValue.graphQLErrors) { + if (observer.next) { + observer.next({ errors: queryStoreValue.graphQLErrors }); + } + } else if (queryStoreValue.networkError) { + // XXX we might not want to re-broadcast the same error over and over if it didn't change + if (observer.error) { + observer.error(queryStoreValue.networkError); + } else { + console.error('Unhandled network error', + queryStoreValue.networkError, + queryStoreValue.networkError.stack); + } + } else { + const resultFromStore = readSelectionSetFromStore({ + store: this.getApolloState().data, + rootId: queryStoreValue.query.id, + selectionSet: queryStoreValue.query.selectionSet, + variables: queryStoreValue.variables, + returnPartialData: options.returnPartialData, + fragmentMap: queryStoreValue.fragmentMap, + }); + + if (observer.next) { + observer.next({ data: resultFromStore }); + } + } + } + }; + } + public watchQuery(options: WatchQueryOptions): ObservableQuery { // Call just to get errors synchronously getQueryDefinition(options.query); @@ -276,6 +341,37 @@ export class QueryManager { } public fetchQuery(queryId: string, options: WatchQueryOptions): Promise { + return this.fetchQueryOverInterface(queryId, options, this.networkInterface); + } + + public generateQueryId() { + const queryId = this.idCounter.toString(); + this.idCounter++; + return queryId; + } + + public stopQueryInStore(queryId: string) { + this.store.dispatch({ + type: 'APOLLO_QUERY_STOP', + queryId, + }); + }; + + public getApolloState(): Store { + return this.store.getState()[this.reduxRootKey]; + } + + public addQueryListener(queryId: string, listener: QueryListener) { + this.queryListeners[queryId] = listener; + }; + + public removeQueryListener(queryId: string) { + delete this.queryListeners[queryId]; + } + + private fetchQueryOverInterface(queryId: string, + options: WatchQueryOptions, + network: NetworkInterface): Promise { const { query, variables, @@ -383,7 +479,13 @@ export class QueryManager { operationName: getOperationName(minimizedQueryDoc), }; - return this.networkInterface.query(request) + const fetchRequest: QueryFetchRequest = { + options: { query: minimizedQueryDoc, variables }, + queryId: queryId, + operationName: request.operationName, + }; + + return this.batcher.enqueueRequest(fetchRequest) .then((result: GraphQLResult) => { // XXX handle multiple GraphQLResults this.store.dispatch({ @@ -420,7 +522,7 @@ export class QueryManager { }); }).catch((error: Error) => { this.store.dispatch({ - type: 'APOLLO_QUERY_ERROR', + type: 'APOLLO_QUERY_ERROR', error, queryId, requestId, @@ -435,10 +537,6 @@ export class QueryManager { }); } - private getApolloState(): Store { - return this.store.getState()[this.reduxRootKey]; - } - private startQuery(options: WatchQueryOptions, listener: QueryListener) { const queryId = this.generateQueryId(); this.queryListeners[queryId] = listener; @@ -484,12 +582,6 @@ export class QueryManager { }); } - private generateQueryId() { - const queryId = this.idCounter.toString(); - this.idCounter++; - return queryId; - } - private generateRequestId() { const requestId = this.idCounter; this.idCounter++; diff --git a/src/batching.ts b/src/batching.ts new file mode 100644 index 00000000000..b5ac9724087 --- /dev/null +++ b/src/batching.ts @@ -0,0 +1,135 @@ +import { + WatchQueryOptions, +} from './QueryManager'; + +import { + NetworkInterface, + Request, + BatchedNetworkInterface, +} from './networkInterface'; + +import { + GraphQLResult, +} from 'graphql'; + +export interface QueryFetchRequest { + options: WatchQueryOptions; + queryId: string; + operationName?: string; + + // promise is created when the query fetch request is + // added to the queue and is resolved once the result is back + // from the server. + promise?: Promise; + resolve?: (result: GraphQLResult) => void; + reject?: (error: Error) => void; +}; + +// QueryBatcher takes a operates on a queue of QueryFetchRequests. It polls and checks this queue +// for new fetch requests. If there are multiple requests in the queue at a time, it will batch +// them together into one query. Batching can be toggled with the shouldBatch option. +export class QueryBatcher { + // Queue on which the QueryBatcher will operate on a per-tick basis. + public queuedRequests: QueryFetchRequest[] = []; + + private shouldBatch: Boolean; + private pollInterval: Number; + private pollTimer: NodeJS.Timer | any; //oddity in Typescript + + //This instance is used to call batchQuery() and send the queries in the + //queue to the server. + private networkInterface: NetworkInterface; + + constructor({ + shouldBatch, + networkInterface, + }: { + shouldBatch: Boolean, + networkInterface: NetworkInterface, + }) { + this.shouldBatch = shouldBatch; + this.queuedRequests = []; + this.networkInterface = networkInterface; + } + + public enqueueRequest(request: QueryFetchRequest): Promise { + this.queuedRequests.push(request); + request.promise = new Promise((resolve, reject) => { + request.resolve = resolve; + request.reject = reject; + }); + + if (!this.shouldBatch) { + this.consumeQueue(); + } + + return request.promise; + } + + // Consumes the queue. Called on a polling interval. + // Returns a list of promises (one for each query). + public consumeQueue(): Promise[] { + if (this.queuedRequests.length < 1) { + return; + } + + const requests: Request[] = this.queuedRequests.map((queuedRequest) => { + return { + query: queuedRequest.options.query, + variables: queuedRequest.options.variables, + operationName: queuedRequest.operationName, + }; + }); + + const promises: Promise[] = []; + const resolvers = []; + const rejecters = []; + this.queuedRequests.forEach((fetchRequest, index) => { + promises.push(fetchRequest.promise); + resolvers.push(fetchRequest.resolve); + rejecters.push(fetchRequest.reject); + }); + + if (this.shouldBatch) { + this.queuedRequests = []; + const batchedPromise = + (this.networkInterface as BatchedNetworkInterface).batchQuery(requests); + + batchedPromise.then((results) => { + results.forEach((result, index) => { + resolvers[index](result); + }); + }).catch((error) => { + rejecters.forEach((rejecter, index) => { + rejecters[index](error); + }); + }); + return promises; + } else { + this.queuedRequests.forEach((fetchRequest, index) => { + this.networkInterface.query(requests[index]).then((result) => { + resolvers[index](result); + }).catch((reason) => { + rejecters[index](reason); + }); + }); + this.queuedRequests = []; + return promises; + } + } + + public start(pollInterval: Number) { + if (this.shouldBatch) { + this.pollInterval = pollInterval; + this.pollTimer = setInterval(() => { + this.consumeQueue(); + }, this.pollInterval); + } + } + + public stop() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + } + } +} diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 00000000000..ef3ca4cb67b --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,146 @@ +// The QueryScheduler is supposed to be a mechanism that schedules polling queries such that +// they are clustered into the time slots of the QueryBatcher and are batched together. It +// also makes sure that for a given polling query, if one instance of the query is inflight, +// another instance will not be fired until the query returns or times out. We do this because +// another query fires while one is already in flight, the data will stay in the "loading" state +// even after the first query has returned. + +// At the moment, the QueryScheduler implements the one-polling-instance-at-a-time logic and +// adds queries to the QueryBatcher queue. + +import { + GraphQLResult, +} from 'graphql'; + +import { + ObservableQuery, + WatchQueryOptions, + QueryManager, + QueryListener, +} from './QueryManager'; + +import assign = require('lodash.assign'); + +export class QueryScheduler { + // Map going from queryIds to query options that are in flight. + public inFlightQueries: { [queryId: string]: WatchQueryOptions }; + + // We use this instance to actually fire queries (i.e. send them to the batching + // mechanism). + private queryManager: QueryManager; + + // Map going from queryIds to polling timers. + private pollingTimers: { [queryId: string]: NodeJS.Timer | any }; // oddity in Typescript + + constructor({ + queryManager, + }: { + queryManager: QueryManager; + }) { + this.queryManager = queryManager; + this.pollingTimers = {}; + this.inFlightQueries = {}; + } + + public checkInFlight(queryId: string) { + return this.inFlightQueries.hasOwnProperty(queryId); + } + + public fetchQuery(queryId: string, options: WatchQueryOptions) { + return new Promise((resolve, reject) => { + this.queryManager.fetchQuery(queryId, options).then((result) => { + this.removeInFlight(queryId); + resolve(result); + }).catch((error) => { + this.removeInFlight(queryId); + reject(error); + }); + this.addInFlight(queryId, options); + }); + } + + public startPollingQuery(options: WatchQueryOptions, listener: QueryListener, + queryId?: string): string { + if (!queryId) { + queryId = this.queryManager.generateQueryId(); + // Fire an initial fetch before we start the polling query + this.fetchQuery(queryId, options); + } + this.queryManager.addQueryListener(queryId, listener); + + this.pollingTimers[queryId] = setInterval(() => { + const pollingOptions = assign({}, options) as WatchQueryOptions; + pollingOptions.forceFetch = true; + + // We only fire the query if another instance of this same polling query isn't + // already in flight. See top of this file for the reasoning as to why we do this. + if (!this.checkInFlight(queryId)) { + this.fetchQuery(queryId, pollingOptions); + } + }, options.pollInterval); + + return queryId; + } + + public stopPollingQuery(queryId: string) { + // TODO should cancel in flight request so that there is no + // further data returned. + this.queryManager.removeQueryListener(queryId); + + if (this.pollingTimers[queryId]) { + clearInterval(this.pollingTimers[queryId]); + } + + // Fire a APOLLO_STOP_QUERY state change to the underlying store. + this.queryManager.stopQueryInStore(queryId); + } + + // Tell the QueryScheduler to schedule the queries fired by a polling query. + public registerPollingQuery(options: WatchQueryOptions): ObservableQuery { + if (!options.pollInterval) { + throw new Error('Tried to register a non-polling query with the scheduler.'); + } + + return new ObservableQuery((observer) => { + // "Fire" (i.e. add to the QueryBatcher queue) + const queryListener = this.queryManager.queryListenerForObserver(options, observer); + const queryId = this.startPollingQuery(options, queryListener); + + return { + unsubscribe: () => { + this.stopPollingQuery(queryId); + }, + + refetch: (variables: any): Promise => { + variables = variables || options.variables; + return this.fetchQuery(queryId, assign(options, { + forceFetch: true, + variables, + }) as WatchQueryOptions); + }, + + startPolling: (pollInterval): void => { + this.pollingTimers[queryId] = setInterval(() => { + const pollingOptions = assign({}, options) as WatchQueryOptions; + pollingOptions.forceFetch = true; + this.fetchQuery(queryId, pollingOptions).then(() => { + this.removeInFlight(queryId); + }); + }, pollInterval); + }, + + stopPolling: (): void => { + this.stopPollingQuery(queryId); + }, + }; + }); + } + + private addInFlight(queryId: string, options: WatchQueryOptions) { + this.inFlightQueries[queryId] = options; + } + + private removeInFlight(queryId: string) { + delete this.inFlightQueries[queryId]; + } +} diff --git a/test/QueryManager.ts b/test/QueryManager.ts index 23ee6091f11..fdcba499e1c 100644 --- a/test/QueryManager.ts +++ b/test/QueryManager.ts @@ -27,6 +27,7 @@ import { import { Document, + GraphQLResult, } from 'graphql'; import ApolloClient from '../src/index'; @@ -39,6 +40,8 @@ import assign = require('lodash.assign'); import mockNetworkInterface from './mocks/mockNetworkInterface'; +import { BatchedNetworkInterface }from '../src/networkInterface'; + describe('QueryManager', () => { it('properly roundtrips through a Redux store', (done) => { const query = gql` @@ -2078,6 +2081,83 @@ describe('QueryManager', () => { done(); }); }); + + describe('batched queries', () => { + it('should batch together two queries fired in the same batcher tick', (done) => { + const query1 = gql` + query { + author { + firstName + lastName + } + }`; + const query2 = gql` + query { + person { + name + } + }`; + const batchedNI: BatchedNetworkInterface = { + query(request: Request): Promise { + //this should never be called. + return null; + }, + + batchQuery(requests: Request[]): Promise { + assert.equal(requests.length, 2); + done(); + return null; + }, + }; + const queryManager = new QueryManager({ + networkInterface: batchedNI, + shouldBatch: true, + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + queryManager.fetchQuery('fake-id', { query: query1 }); + queryManager.fetchQuery('even-more-fake-id', { query: query2 }); + }); + + it('should not batch together queries that are on different batcher ticks', (done) => { + const query1 = gql` + query { + author { + firstName + lastName + } + }`; + const query2 = gql` + query { + person { + name + } + }`; + const batchedNI: BatchedNetworkInterface = { + query(request: Request): Promise { + return null; + }, + + batchQuery(requests: Request[]): Promise { + assert.equal(requests.length, 1); + return new Promise((resolve, reject) => { + // never resolve the promise. + }); + }, + }; + const queryManager = new QueryManager({ + networkInterface: batchedNI, + shouldBatch: true, + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + queryManager.fetchQuery('super-fake-id', { query: query1 }); + setTimeout(() => { + queryManager.fetchQuery('very-fake-id', { query: query2 }); + done(); + }, 100); + }); + }); }); function testDiffing( diff --git a/test/batching.ts b/test/batching.ts new file mode 100644 index 00000000000..67f6e3f6f03 --- /dev/null +++ b/test/batching.ts @@ -0,0 +1,348 @@ +import { QueryBatcher, + QueryFetchRequest, + } from '../src/batching'; +import { assert } from 'chai'; +import mockNetworkInterface, { + mockBatchedNetworkInterface, +} from './mocks/mockNetworkInterface'; +import gql from '../src/gql'; +import { GraphQLResult } from 'graphql'; + +const networkInterface = mockNetworkInterface(); + +describe('QueryBatcher', () => { + it('should construct', () => { + assert.doesNotThrow(() => { + const querySched = new QueryBatcher({ + shouldBatch: true, + networkInterface, + }); + querySched.consumeQueue(); + }); + }); + + it('should not do anything when faced with an empty queue', () => { + const batcher = new QueryBatcher({ + shouldBatch: true, + networkInterface, + }); + + assert.equal(batcher.queuedRequests.length, 0); + batcher.consumeQueue(); + assert.equal(batcher.queuedRequests.length, 0); + }); + + it('should be able to add to the queue', () => { + const batcher = new QueryBatcher({ + shouldBatch: true, + networkInterface, + }); + + const query = gql` + query { + author { + firstName + lastName + } + }`; + + const request: QueryFetchRequest = { + options: { query }, + queryId: 'not-a-real-id', + }; + + assert.equal(batcher.queuedRequests.length, 0); + batcher.enqueueRequest(request); + assert.equal(batcher.queuedRequests.length, 1); + batcher.enqueueRequest(request); + assert.equal(batcher.queuedRequests.length, 2); + }); + + describe('request queue', () => { + const query = gql` + query { + author { + firstName + lastName + } + }`; + const data = { + 'author' : { + 'firstName': 'John', + 'lastName': 'Smith', + }, + }; + const myNetworkInterface = mockBatchedNetworkInterface( + { + request: { query }, + result: { data }, + }, + { + request: { query }, + result: { data }, + } + ); + const batcher = new QueryBatcher({ + shouldBatch: true, + networkInterface: myNetworkInterface, + }); + const request: QueryFetchRequest = { + options: { query }, + queryId: 'not-a-real-id', + }; + + it('should be able to consume from a queue containing a single query', (done) => { + const myBatcher = new QueryBatcher({ + shouldBatch: true, + networkInterface: myNetworkInterface, + }); + + myBatcher.enqueueRequest(request); + const promises: Promise[] = myBatcher.consumeQueue(); + assert.equal(promises.length, 1); + promises[0].then((resultObj) => { + assert.equal(myBatcher.queuedRequests.length, 0); + assert.deepEqual(resultObj, { data } ); + done(); + }); + }); + + it('should be able to consume from a queue containing multiple queries', (done) => { + const request2 = { + options: { query }, + queryId: 'another-fake-id', + }; + const myBatcher = new QueryBatcher({ + shouldBatch: true, + networkInterface: mockBatchedNetworkInterface( + { + request: { query }, + result: {data }, + }, + { + request: { query }, + result: { data }, + } + ), + }); + myBatcher.enqueueRequest(request); + myBatcher.enqueueRequest(request2); + const promises: Promise[] = myBatcher.consumeQueue(); + assert.equal(batcher.queuedRequests.length, 0); + assert.equal(promises.length, 2); + promises[0].then((resultObj1) => { + assert.deepEqual(resultObj1, { data }); + promises[1].then((resultObj2) => { + assert.deepEqual(resultObj2, { data }); + done(); + }); + }); + }); + + it('should return a promise when we enqueue a request and resolve it with a result', (done) => { + const myBatcher = new QueryBatcher({ + shouldBatch: true, + networkInterface: mockBatchedNetworkInterface( + { + request: { query }, + result: { data }, + } + ), + }); + const promise = myBatcher.enqueueRequest(request); + myBatcher.consumeQueue(); + promise.then((result) => { + assert.deepEqual(result, { data }); + done(); + }); + }); + }); + + it('should be able to stop polling', () => { + const batcher = new QueryBatcher({ + shouldBatch: true, + networkInterface, + }); + const query = gql` + query { + author { + firstName + lastName + } + }`; + const request = { + options: { query }, + queryId: 'not-a-real-id', + }; + + batcher.enqueueRequest(request); + batcher.enqueueRequest(request); + + //poll with a big interval so that the queue + //won't actually be consumed by the time we stop. + batcher.start(1000); + batcher.stop(); + assert.equal(batcher.queuedRequests.length, 2); + }); + + it('should resolve the promise returned when we enqueue with shouldBatch: false', (done) => { + const query = gql` + query { + author { + firstName + lastName + } + }`; + const myRequest = { + options: { query }, + queryId: 'not-a-real-id', + }; + + const data = { + author: { + firstName: 'John', + lastName: 'Smith', + }, + }; + const myNetworkInterface = mockNetworkInterface( + { + request: { query }, + result: { data }, + } + ); + const batcher = new QueryBatcher({ + shouldBatch: false, + networkInterface: myNetworkInterface, + }); + const promise = batcher.enqueueRequest(myRequest); + batcher.consumeQueue(); + promise.then((result) => { + assert.deepEqual(result, { data }); + done(); + }); + }); + + it('should immediately consume the queue when we enqueue with shouldBatch: false', (done) => { + const query = gql` + query { + author { + firstName + lastName + } + }`; + const myRequest = { + options: { query }, + queryId: 'not-a-real-id', + }; + + const data = { + author: { + firstName: 'John', + lastName: 'Smith', + }, + }; + const myNetworkInterface = mockNetworkInterface( + { + request: { query }, + result: { data }, + } + ); + const batcher = new QueryBatcher({ + shouldBatch: false, + networkInterface: myNetworkInterface, + }); + const promise = batcher.enqueueRequest(myRequest); + promise.then((result) => { + assert.deepEqual(result, { data }); + done(); + }); + }); + + it('should reject the promise if there is a network error with batch:true', (done) => { + const query = gql` + query { + author { + firstName + lastName + } + }`; + const request = { + options: { query }, + queryId: 'very-real-id', + }; + const error = new Error('Network error'); + const myNetworkInterface = mockBatchedNetworkInterface( + { + request: { query }, + error, + } + ); + const batcher = new QueryBatcher({ + shouldBatch: true, + networkInterface: myNetworkInterface, + }); + const promise = batcher.enqueueRequest(request); + batcher.consumeQueue(); + promise.catch((resError: Error) => { + assert.equal(resError.message, 'Network error'); + done(); + }); + }); + + it('should reject the promise if there is a network error with batch:false', (done) => { + const query = gql` + query { + author { + firstName + lastName + } + }`; + const request = { + options: { query }, + queryId: 'super-real-id', + }; + const error = new Error('Network error'); + const myNetworkInterface = mockNetworkInterface( + { + request: { query }, + error, + } + ); + const batcher = new QueryBatcher({ + shouldBatch: false, + networkInterface: myNetworkInterface, + }); + const promise = batcher.enqueueRequest(request); + batcher.consumeQueue(); + promise.catch((resError: Error) => { + assert.equal(resError.message, 'Network error'); + done(); + }); + }); + + it('should not start polling if shouldBatch is false', (done) => { + const query = gql` + query { + author { + firstName + lastName + } + }`; + const fetchRequest = { + options: { query }, + queryId: 'super-real-id', + }; + const batcher = new QueryBatcher({ + shouldBatch: false, + networkInterface: mockNetworkInterface({ + request: { query }, + }), + }); + batcher.start(1); + batcher.queuedRequests.push(fetchRequest); + setTimeout(() => { + assert.equal(batcher.queuedRequests.length, 1); + done(); + }); + }); +}); diff --git a/test/mocks/mockNetworkInterface.ts b/test/mocks/mockNetworkInterface.ts index ebc7458cf49..016aa0cba4c 100644 --- a/test/mocks/mockNetworkInterface.ts +++ b/test/mocks/mockNetworkInterface.ts @@ -1,5 +1,6 @@ import { NetworkInterface, + BatchedNetworkInterface, Request, } from '../../src/networkInterface'; @@ -17,6 +18,12 @@ export default function mockNetworkInterface( return new MockNetworkInterface(...mockedResponses); } +export function mockBatchedNetworkInterface( + ...mockedResponses: MockedResponse[] +): NetworkInterface { + return new MockBatchedNetworkInterface(...mockedResponses); +} + export interface ParsedRequest { variables?: Object; query?: Document; @@ -80,6 +87,19 @@ export class MockNetworkInterface implements NetworkInterface { } } +export class MockBatchedNetworkInterface +extends MockNetworkInterface implements BatchedNetworkInterface { + public batchQuery(requests: Request[]): Promise { + const resultPromises: Promise[] = []; + requests.forEach((request) => { + resultPromises.push(this.query(request)); + }); + + return Promise.all(resultPromises); + } +} + + function requestToKey(request: ParsedRequest): string { const queryString = request.query && print(request.query); diff --git a/test/queryMerging.ts b/test/queryMerging.ts index e48bae7787d..a25849fec2f 100644 --- a/test/queryMerging.ts +++ b/test/queryMerging.ts @@ -27,8 +27,10 @@ import { import gql from '../src/gql'; import { assert } from 'chai'; +import cloneDeep = require('lodash.clonedeep'); describe('Query merging', () => { + it('should be able to add a prefix to a variables object', () => { const variables = { 'offset': 15, @@ -82,7 +84,8 @@ describe('Query merging', () => { const expQueryDefinition = getQueryDefinition(expQuery); const queryField = queryDef.selectionSet.selections[0]; const expField = expQueryDefinition.selectionSet.selections[0]; - const resField = aliasField(queryField as Field, 'listOfAuthors'); + const queryFieldCopy = cloneDeep(queryField); + const resField = aliasField(queryFieldCopy as Field, 'listOfAuthors'); assert.deepEqual(print(resField), print(expField)); }); diff --git a/test/scheduler.ts b/test/scheduler.ts new file mode 100644 index 00000000000..646c7fd1abe --- /dev/null +++ b/test/scheduler.ts @@ -0,0 +1,334 @@ +import { QueryScheduler } from '../src/scheduler'; +import { assert } from 'chai'; +import { + WatchQueryOptions, + QueryManager, +} from '../src/QueryManager'; +import { + createApolloStore, +} from '../src/store'; +import mockNetworkInterface from './mocks/mockNetworkInterface'; +import gql from '../src/gql'; + +describe('QueryScheduler', () => { + it('should throw an error if we try to register a non-polling query', () => { + const queryManager = new QueryManager({ + networkInterface: mockNetworkInterface(), + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + + const scheduler = new QueryScheduler({ + queryManager, + }); + + const query = gql` + query { + author { + firstName + lastName + } + }`; + const queryOptions: WatchQueryOptions = { + query, + }; + assert.throws(() => { + scheduler.registerPollingQuery(queryOptions); + }); + }); + + it('should correctly start polling queries', (done) => { + const query = gql` + query { + author { + firstName + lastName + } + }`; + + const data = { + 'author': { + 'firstName': 'John', + 'lastName': 'Smith', + }, + }; + const queryOptions = { + query, + pollInterval: 80, + }; + + const networkInterface = mockNetworkInterface( + { + request: queryOptions, + result: { data }, + } + ); + const queryManager = new QueryManager({ + networkInterface: networkInterface, + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + const scheduler = new QueryScheduler({ + queryManager, + }); + let timesFired = 0; + const queryId = scheduler.startPollingQuery(queryOptions, (queryStoreValue) => { + timesFired += 1; + }); + setTimeout(() => { + assert.isAtLeast(timesFired, 0); + scheduler.stopPollingQuery(queryId); + done(); + }, 120); + }); + + it('should correctly stop polling queries', (done) => { + const query = gql` + query { + someAlias: author { + firstName + lastName + } + }`; + const data = { + 'someAlias': { + 'firstName': 'John', + 'lastName': 'Smith', + }, + }; + const queryOptions = { + query, + pollInterval: 20, + }; + const networkInterface = mockNetworkInterface( + { + request: queryOptions, + result: { data }, + } + ); + const queryManager = new QueryManager({ + networkInterface: networkInterface, + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + const scheduler = new QueryScheduler({ + queryManager, + }); + let timesFired = 0; + let queryId = scheduler.startPollingQuery(queryOptions, (queryStoreValue) => { + timesFired += 1; + scheduler.stopPollingQuery(queryId); + }); + + setTimeout(() => { + assert.equal(timesFired, 1); + done(); + }, 170); + }); + + it('should register a query and return an observable that can be unsubscribed', (done) => { + const myQuery = gql` + query { + someAuthorAlias: author { + firstName + lastName + } + }`; + const data = { + 'someAuthorAlias': { + 'firstName': 'John', + 'lastName': 'Smith', + }, + }; + const queryOptions = { + query: myQuery, + pollInterval: 20, + }; + const networkInterface = mockNetworkInterface( + { + request: queryOptions, + result: { data }, + } + ); + const queryManager = new QueryManager({ + networkInterface, + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + const scheduler = new QueryScheduler({ + queryManager, + }); + let timesFired = 0; + let observableQuery = scheduler.registerPollingQuery(queryOptions); + let subscription = observableQuery.subscribe({ + next(result) { + timesFired += 1; + assert.deepEqual(result, { data }); + subscription.unsubscribe(); + }, + }); + + setTimeout(() => { + assert.equal(timesFired, 1); + done(); + }, 100); + }); + + it('should handle network errors on polling queries correctly', (done) => { + const query = gql` + query { + author { + firstName + lastName + } + }`; + const error = new Error('something went terribly wrong'); + const queryOptions = { + query, + pollInterval: 80, + }; + const networkInterface = mockNetworkInterface( + { + request: queryOptions, + error, + } + ); + const queryManager = new QueryManager({ + networkInterface, + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + const scheduler = new QueryScheduler({ + queryManager, + }); + let observableQuery = scheduler.registerPollingQuery(queryOptions); + const subscription = observableQuery.subscribe({ + next(result) { + done(new Error('Observer provided a result despite a network error.')); + }, + + error(errorVal) { + assert(errorVal); + subscription.unsubscribe(); + done(); + }, + }); + }); + + it('should handle graphql errors on polling queries correctly', (done) => { + const query = gql` + query { + author { + firstName + lastName + } + }`; + const errors = [new Error('oh no something went wrong')]; + const queryOptions = { + query, + pollInterval: 80, + }; + const networkInterface = mockNetworkInterface( + { + request: queryOptions, + result: { errors }, + } + ); + const queryManager = new QueryManager({ + networkInterface, + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + const scheduler = new QueryScheduler({ + queryManager, + }); + let observableQuery = scheduler.registerPollingQuery(queryOptions); + const subscription = observableQuery.subscribe({ + error(errorVal) { + subscription.unsubscribe(); + assert(errorVal); + done(); + }, + }); + }); + it('should keep track of in flight queries', (done) => { + const query = gql` + query { + fortuneCookie + }`; + const data = { + 'fortuneCookie': 'lol', + }; + const queryOptions = { + query, + pollInterval: 70, + }; + const networkInterface = mockNetworkInterface( + { + request: queryOptions, + result: { data }, + delay: 20000, //i.e. should never return + }, + { + request: queryOptions, + result: { data }, + delay: 20000, + } + ); + const queryManager = new QueryManager({ + networkInterface, + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + const scheduler = new QueryScheduler({ + queryManager, + }); + const observer = scheduler.registerPollingQuery(queryOptions); + const subscription = observer.subscribe({}); + + // as soon as we register a query, there should be an addition to the query map. + assert.equal(Object.keys(scheduler.inFlightQueries).length, 1); + setTimeout(() => { + assert.equal(Object.keys(scheduler.inFlightQueries).length, 1); + assert.deepEqual(scheduler.inFlightQueries[0], queryOptions); + subscription.unsubscribe(); + done(); + }, 100); + }); + + it('should not fire another query if one with the same id is in flight', (done) => { + const query = gql` + query { + fortuneCookie + }`; + const data = { + 'fortuneCookie': 'you will live a long life', + }; + const queryOptions = { + query, + pollInterval: 10, + }; + const networkInterface = mockNetworkInterface( + { + request: queryOptions, + result: { data }, + delay: 20000, + } + ); + const queryManager = new QueryManager({ + networkInterface, + store: createApolloStore(), + reduxRootKey: 'apollo', + }); + const scheduler = new QueryScheduler({ + queryManager, + }); + const observer = scheduler.registerPollingQuery(queryOptions); + const subscription = observer.subscribe({}); + setTimeout(() => { + assert.equal(Object.keys(scheduler.inFlightQueries).length, 1); + subscription.unsubscribe(); + done(); + }, 100); + }); +}); diff --git a/test/scheduler.ts~ b/test/scheduler.ts~ new file mode 100644 index 00000000000..010fe4d4448 --- /dev/null +++ b/test/scheduler.ts~ @@ -0,0 +1 @@ +import { assert } from 'chai' diff --git a/test/tests.ts b/test/tests.ts index 7c0ecb62ff1..ebfb50b4be9 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -21,3 +21,5 @@ import './queryTransform'; import './getFromAST'; import './directives'; import './queryMerging'; +import './batching'; +import './scheduler';