├── .gitignore ├── .travis.yml ├── .vscode └── settings.json ├── examples ├── graphql-tools.js ├── graphql.js └── data.js ├── LICENSE ├── package.json ├── src ├── batch.js └── batch.test.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | lib 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 6 5 | - 4 6 | 7 | cache: 8 | directories: 9 | - node_modules 10 | - $NVM_DIR 11 | 12 | scripts: 13 | - npm run ci 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "prettier.bracketSpacing": true, 4 | "prettier.printWidth": 80, 5 | "prettier.singleQuote": true, 6 | "prettier.tabWidth": 2, 7 | "prettier.trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /examples/graphql-tools.js: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from 'graphql-tools'; 2 | import { createBatchResolver } from '../src/batch'; 3 | import { getUser, getFriendsForUsers } from './data'; 4 | 5 | const typeDefs = ` 6 | type User { 7 | id: Int 8 | name: String 9 | friends(limit: Int!): [User] 10 | } 11 | 12 | type Query { 13 | user(id: Int!): User 14 | } 15 | 16 | schema { 17 | query: Query 18 | } 19 | `; 20 | 21 | const resolvers = { 22 | User: { 23 | friends: createBatchResolver((users, { limit }) => { 24 | console.log('Resolving friends 👫'); // eslint-disable-line no-console 25 | return getFriendsForUsers(users, limit); 26 | }), 27 | }, 28 | Query: { 29 | user(_, { id }) { 30 | return getUser(id); 31 | }, 32 | }, 33 | }; 34 | 35 | const Schema = makeExecutableSchema({ 36 | typeDefs, 37 | resolvers, 38 | }); 39 | 40 | export default Schema; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Caleb Meredith 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /examples/graphql.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLInt, 4 | GraphQLString, 5 | GraphQLList, 6 | GraphQLNonNull, 7 | GraphQLSchema, 8 | } from 'graphql'; 9 | import { createBatchResolver } from '../src/batch'; 10 | import { getUser, getFriendsForUsers } from './data'; 11 | 12 | const UserType = new GraphQLObjectType({ 13 | name: 'User', 14 | fields: () => ({ 15 | id: { type: GraphQLInt }, 16 | name: { type: GraphQLString }, 17 | friends: { 18 | type: new GraphQLList(UserType), 19 | args: { 20 | limit: { type: GraphQLInt }, 21 | }, 22 | resolve: createBatchResolver((users, { limit }) => { 23 | console.log('Resolving friends 👫'); // eslint-disable-line no-console 24 | return getFriendsForUsers(users, limit); 25 | }), 26 | }, 27 | }), 28 | }); 29 | 30 | const QueryType = new GraphQLObjectType({ 31 | name: 'Query', 32 | fields: { 33 | user: { 34 | type: UserType, 35 | args: { 36 | id: { type: new GraphQLNonNull(GraphQLInt) }, 37 | }, 38 | resolve: (source, { id }) => getUser(id), 39 | }, 40 | }, 41 | }); 42 | 43 | const Schema = new GraphQLSchema({ 44 | query: QueryType, 45 | }); 46 | 47 | export default Schema; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-resolve-batch", 3 | "version": "1.0.3", 4 | "description": "A GraphQL batching model which groups execution by GraphQL fields.", 5 | "author": "Caleb Meredith ", 6 | "license": "MIT", 7 | "homepage": "https://github.com/calebmer/graphql-resolve-batch#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/calebmer/graphql-resolve-batch.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/calebmer/graphql-resolve-batch/issues" 14 | }, 15 | "keywords": [ 16 | "graphql", 17 | "batch", 18 | "resolve", 19 | "field" 20 | ], 21 | "main": "lib/batch.js", 22 | "files": [ 23 | "package.json", 24 | "README.md", 25 | "LICENSE", 26 | "lib/batch.js", 27 | "src/batch.js" 28 | ], 29 | "scripts": { 30 | "preversion": "npm run ci", 31 | "prepublish": "npm run build", 32 | "format": "prettier 'src/**/*.js' 'examples/**/*.js' --write --print-width 80 --tab-width 2 --single-quote --trailing-comma all", 33 | "lint": "eslint 'src/**/*.js'", 34 | "test": "jest", 35 | "test-watch": "jest --watch", 36 | "ci": "npm run format && git diff --exit-code && npm run lint && npm test", 37 | "build": "rm -rf lib && babel src --out-dir lib" 38 | }, 39 | "devDependencies": { 40 | "babel-cli": "^6.23.0", 41 | "babel-core": "^6.23.1", 42 | "babel-eslint": "^7.1.1", 43 | "babel-jest": "^19.0.0", 44 | "babel-preset-env": "^1.1.10", 45 | "babel-preset-es2015": "^6.22.0", 46 | "eslint": "^3.16.1", 47 | "graphql": "^0.9.1", 48 | "graphql-tools": "^0.10.1", 49 | "jest": "^19.0.2", 50 | "prettier": "^0.19.0" 51 | }, 52 | "babel": { 53 | "presets": [ 54 | [ 55 | "env", 56 | { 57 | "targets": { 58 | "node": 4 59 | } 60 | } 61 | ] 62 | ] 63 | }, 64 | "eslintConfig": { 65 | "parser": "babel-eslint", 66 | "env": { 67 | "node": true, 68 | "es6": true, 69 | "jest": true 70 | }, 71 | "extends": [ 72 | "eslint:recommended" 73 | ] 74 | }, 75 | "jest": { 76 | "roots": [ 77 | "/src/" 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /examples/data.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains the data that is used in our examples. This file does not 3 | * contain an example itself. For a real example see the other files in the 4 | * examples folder. 5 | */ 6 | 7 | /** 8 | * Lookup a user by an id and return that user. 9 | */ 10 | export function getUser(id) { 11 | return userByID.get(id); 12 | } 13 | 14 | /** 15 | * A batch function which will take an array of users and return an array of the 16 | * friends for each user. 17 | * 18 | * If a limit is provided then it will configure the maximum number of friends 19 | * that are returned. If no limit is provided then all of the friends are 20 | * returned. 21 | */ 22 | export function getFriendsForUsers(users, limit = Infinity) { 23 | return users.map(user => { 24 | const allFriendIDs = friendsByID.get(user.id) || []; 25 | const friendIDs = allFriendIDs.slice(0, limit + 1); 26 | return friendIDs.map(id => getUser(id)); 27 | }); 28 | } 29 | 30 | /** 31 | * Our raw user data. We have a small set of named users with ids. 32 | */ 33 | const rawUsers = [ 34 | { id: 1, name: 'Shirley Hanson' }, 35 | { id: 2, name: 'Linda Bishop' }, 36 | { id: 3, name: 'Arthur Ford' }, 37 | { id: 4, name: 'Martha Franklin' }, 38 | { id: 5, name: 'Helen Gutierrez' }, 39 | { id: 6, name: 'Mary Gonzalez' }, 40 | { id: 7, name: 'Christina Mcdonald' }, 41 | { id: 8, name: 'Alice Ryan' }, 42 | { id: 9, name: 'Samuel Harrison' }, 43 | { id: 10, name: 'Christopher Ellis' }, 44 | { id: 11, name: 'Matthew Spencer' }, 45 | { id: 12, name: 'Julie Reid' }, 46 | { id: 13, name: 'Elizabeth Freeman' }, 47 | { id: 14, name: 'Jose Vasquez' }, 48 | { id: 15, name: 'Martha Henderson' }, 49 | { id: 16, name: 'Virginia Butler' }, 50 | { id: 17, name: 'Mark Fernandez' }, 51 | { id: 18, name: 'Martin Cole' }, 52 | { id: 19, name: 'Anna Price' }, 53 | { id: 20, name: 'Debra Henderson' }, 54 | { id: 21, name: 'Barbara Carroll' }, 55 | { id: 22, name: 'Jennifer Weaver' }, 56 | { id: 23, name: 'Dennis Hart' }, 57 | { id: 24, name: 'Chris Ryan' }, 58 | { id: 25, name: 'Alan Rivera' }, 59 | ]; 60 | 61 | /** 62 | * Convert our user data into a map where we can have efficient searches for 63 | * users by id. 64 | */ 65 | const userByID = new Map(rawUsers.map(user => [user.id, user])); 66 | 67 | /** 68 | * Raw friendship data. It is an array of distinct pairs in which no pairs are a 69 | * duplicate. 70 | * 71 | * In other words we do not have the pairs `[1, 2]` and `[2, 1]` as they are not 72 | * distinct. They both have the ids `1` and `2`. We also do not have a pair like 73 | * `[1, 1]` which has duplicate `1`s. 74 | * 75 | * This array and the pairs within are sorted to make it easy to spot incorrect 76 | * pairs. 77 | */ 78 | const rawFriendships = [ 79 | [1, 5], 80 | [1, 6], 81 | [1, 10], 82 | [1, 11], 83 | [1, 17], 84 | [1, 19], 85 | [1, 20], 86 | [1, 22], 87 | [1, 25], 88 | [2, 7], 89 | [2, 10], 90 | [2, 21], 91 | [2, 22], 92 | [3, 15], 93 | [3, 18], 94 | [3, 20], 95 | [3, 21], 96 | [3, 22], 97 | [3, 25], 98 | [4, 10], 99 | [4, 14], 100 | [4, 17], 101 | [4, 19], 102 | [4, 21], 103 | [5, 6], 104 | [5, 9], 105 | [5, 11], 106 | [5, 12], 107 | [5, 18], 108 | [6, 7], 109 | [6, 9], 110 | [6, 16], 111 | [6, 17], 112 | [7, 8], 113 | [7, 9], 114 | [7, 11], 115 | [7, 12], 116 | [7, 18], 117 | [7, 19], 118 | [7, 22], 119 | [8, 16], 120 | [8, 22], 121 | [9, 10], 122 | [9, 12], 123 | [9, 14], 124 | [9, 20], 125 | [9, 24], 126 | [9, 25], 127 | [10, 12], 128 | [10, 18], 129 | [11, 13], 130 | [11, 15], 131 | [11, 17], 132 | [11, 18], 133 | [11, 21], 134 | [11, 24], 135 | [12, 20], 136 | [13, 17], 137 | [13, 19], 138 | [13, 21], 139 | [14, 23], 140 | [15, 20], 141 | [15, 22], 142 | [16, 18], 143 | [16, 20], 144 | [16, 24], 145 | [16, 25], 146 | [17, 18], 147 | [17, 20], 148 | [17, 23], 149 | [17, 24], 150 | [18, 22], 151 | [18, 25], 152 | [19, 21], 153 | [19, 23], 154 | [20, 22], 155 | [21, 25], 156 | ]; 157 | 158 | /** 159 | * A map of a user id to the ids that user’s friends. 160 | */ 161 | const friendsByID = new Map(); 162 | 163 | { 164 | // Populate the `friendsByID` array using the `addFriendOneWay` utility 165 | // function. 166 | rawFriendships.forEach(([id1, id2]) => { 167 | addFriendOneWay(id1, id2); 168 | addFriendOneWay(id2, id1); 169 | }); 170 | 171 | /** 172 | * A utility function we use to populate the `friendsByID` map. It adds a 173 | * directed friendship. Call this function twice, the second time with 174 | * arguments reversed, for the friendship to go both ways. 175 | */ 176 | function addFriendOneWay(id1, id2) { 177 | if (friendsByID.has(id1)) { 178 | friendsByID.get(id1).push(id2); 179 | } else { 180 | friendsByID.set(id1, [id2]); 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/batch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a GraphQL.js field resolver that batches together multiple resolves 3 | * together that share the *exact* same GraphQL field selection. 4 | */ 5 | export function createBatchResolver(batchResolveFn) { 6 | // Throw an error early of the batch resolver is not a function instead of 7 | // throwing an error at query execution time. 8 | if (typeof batchResolveFn !== 'function') { 9 | throw new Error( 10 | 'Expected a function as the first argument when creating a batch ' + 11 | `resolver. Instead found: '${typeof batchResolveFn}'.`, 12 | ); 13 | } 14 | const batcher = new Batcher(batchResolveFn); 15 | return (source, args, context, info) => { 16 | return new Promise((resolve, reject) => { 17 | batcher.batch(source, args, context, info, resolve, reject); 18 | }); 19 | }; 20 | } 21 | 22 | /** 23 | * A structure that batches GraphQL resolves together based on the GraphQL field 24 | * nodes. 25 | */ 26 | class Batcher { 27 | constructor(batchResolveFn) { 28 | this._batchResolveFn = batchResolveFn; 29 | this._batches = new Map(); 30 | } 31 | 32 | /** 33 | * Registers a batch execution for the provided GraphQL field node ASTs. The 34 | * batch resolver function should be scheduled to execute on the next tick and 35 | * the batch resolver finishes the `resolve` and `reject` functions will be 36 | * executed. 37 | * 38 | * We group batches together by the first item in `info.fieldNodes`. 39 | */ 40 | batch(source, args, context, info, resolve, reject) { 41 | // We only use the first field node because the array is reconstructed for 42 | // every value. Using only the first node *should not matter*. The nodes 43 | // should not get reused and we should not be missing any information from 44 | // the other fields. 45 | const { fieldNodes: [fieldNode] } = info; 46 | let batch = this._batches.get(fieldNode); 47 | 48 | // If no batch currently exists for this array of field nodes then we want 49 | // to create one. 50 | if (typeof batch === 'undefined') { 51 | batch = { 52 | // We only use the first set of `args`, `context`, and `info` that we 53 | // are passed. 54 | // 55 | // It is mostly safe to assume that these variables will be the same 56 | // for the same `fieldNodes` from the execution implementation. 57 | args, 58 | context, 59 | info, 60 | 61 | // We will push our sources and promise callbacks into these arrays. 62 | sources: [], 63 | callbacks: [], 64 | }; 65 | this._batches.set(fieldNode, batch); 66 | } 67 | 68 | // Add our source and callbacks to the batch. 69 | batch.sources.push(source); 70 | batch.callbacks.push({ resolve, reject }); 71 | 72 | // Schedule a resolve if none has already been scheduled. 73 | this._scheduleResolve(); 74 | } 75 | 76 | /** 77 | * Schedules a resolve for the next tick if a resolve has not already been 78 | * scheduled. 79 | */ 80 | _scheduleResolve() { 81 | if (!this._hasScheduledResolve) { 82 | this._hasScheduledResolve = true; 83 | process.nextTick(() => { 84 | this._resolve(); 85 | this._hasScheduledResolve = false; 86 | }); 87 | } 88 | } 89 | 90 | /** 91 | * Resolves all of the batch callbacks by actually executing the batch 92 | * resolver. 93 | */ 94 | _resolve() { 95 | // Execute every batch that has accumulated. 96 | this._batches.forEach(batch => { 97 | // Execute our batch resolver function with the appropriate arguments. We 98 | // use the `executePromise` function to normalize synchronous and 99 | // asynchronous execution. 100 | executePromise( 101 | this._batchResolveFn, 102 | batch.sources, 103 | batch.args, 104 | batch.context, 105 | batch.info, 106 | ) 107 | .then( 108 | // If we got back an array of values then we want to resolve all of our 109 | // callbacks for this batch. 110 | values => { 111 | // Throw an error if we did not get an array of values back from the 112 | // batch resolver function. 113 | if (!Array.isArray(values)) { 114 | throw new Error( 115 | 'Must return an array of values from the batch resolver ' + 116 | 'function. Instead the function returned a ' + 117 | `'${Object.prototype.toString.call(values)}'.`, 118 | ); 119 | } 120 | // Throw an error if the array of values we got back from the resolver 121 | // is not equal to the number of values we expected when looking at 122 | // the sources. 123 | if (values.length !== batch.sources.length) { 124 | throw new Error( 125 | 'Must return the same number of values from the batch ' + 126 | 'resolver as there were sources. Expected ' + 127 | `${batch.sources.length} value(s) but got ` + 128 | `${values.length} value(s).`, 129 | ); 130 | } 131 | // We want to call all of our callbacks with a value returned by our 132 | // batch resolver. 133 | batch.callbacks.forEach(({ resolve, reject }, i) => { 134 | // Get the value for this set of callbacks. If it is an error then 135 | // we want to reject this promise. Otherwise we will resolve to the 136 | // value. 137 | const value = values[i]; 138 | if (value instanceof Error) { 139 | reject(value); 140 | } else { 141 | resolve(value); 142 | } 143 | }); 144 | }, 145 | ) 146 | // If we got an error we want to reject all of our callbacks. 147 | .catch(error => { 148 | batch.callbacks.forEach(({ reject }) => { 149 | reject(error); 150 | }); 151 | }); 152 | }); 153 | // Clean out our batches map. 154 | this._batches.clear(); 155 | } 156 | } 157 | 158 | /** 159 | * Executes a function and *always* returns a function. If the function executes 160 | * synchronously then the result will be coerced into a promise, and if the 161 | * function returns a promise then that promise will be returned. 162 | */ 163 | function executePromise(fn, ...args) { 164 | try { 165 | // Execute the function. We do not bind a `this` variable. This is 166 | // expected to be done by the caller. 167 | const result = fn(...args); 168 | // If the result is thenable (most likely a promise) then we want to return 169 | // the result directly. Otherwise we will turn the value into a promise with 170 | // `Promise.resolve`. 171 | return result && typeof result.then === 'function' 172 | ? result 173 | : Promise.resolve(result); 174 | } catch (error) { 175 | // If the functioned errored synchronously we want to return a promise that 176 | // immeadiately rejects. 177 | return Promise.reject(error); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Batch Resolver 2 | 3 | A method for batching the resoluition of GraphQL fields as an alternative to [`dataloader`][] that works with both [GraphQL.js][] and [`graphql-tools`][]. 4 | 5 | [`dataloader`]: https://github.com/facebook/dataloader 6 | 7 | ```js 8 | import { GraphQLObjectType, GraphQLString } from 'graphql'; 9 | import { createBatchResolver } from 'graphql-resolve-batch'; 10 | 11 | const UserType = new GraphQLObjectType({ 12 | // ... 13 | }); 14 | 15 | const QueryType = new GraphQLObjectType({ 16 | name: 'Query', 17 | fields: { 18 | user: { 19 | type: UserType, 20 | resolve: createBatchResolver(async (sources, args, context) => { 21 | const { db } = context; 22 | const users = await db.loadUsersByIds(sources.map(({ id }) => id)); 23 | return users; 24 | }), 25 | }, 26 | }, 27 | }); 28 | ``` 29 | 30 | For a complete examples with usage for both [GraphQL.js][] and [`graphql-tools`][], be sure to check out the [**`./examples` directory**][]. 31 | 32 | [GraphQL.js]: https://github.com/graphql/graphql-js 33 | [`graphql-tools`]: https://github.com/apollographql/graphql-tools 34 | [**`./examples` directory**]: https://github.com/calebmer/graphql-resolve-batch/tree/master/examples 35 | 36 | ## Installation 37 | 38 | `graphql-resolve-batch` has a peer dependency on `graphql`, so make sure you have installed that package as well. 39 | 40 | ``` 41 | npm install --save graphql graphql-resolve-batch 42 | ``` 43 | 44 | [`graphql`]: https://github.com/graphql/graphql-js 45 | 46 | ## Why? 47 | 48 | GraphQL is a powerful data querying language for both frontend and backend developers. However, because of how GraphQL queries are executed, it can be difficult to define an efficient GraphQL schema. Take for example the following query: 49 | 50 | ```graphql 51 | { 52 | users(limit: 5) { 53 | name 54 | friends(limit: 5) { 55 | name 56 | } 57 | } 58 | } 59 | ``` 60 | 61 | This demonstrates the power of GraphQL to select arbitrarily nested data. Yet it is a difficult pattern to optimize from the schema developer’s perspective. If we naïvely translate this GraphQL query into say, SQL, we get the following pseudo queries: 62 | 63 | ``` 64 | Select the first 5 users. 65 | Select the first 5 friends for the first user. 66 | Select the first 5 friends for the second user. 67 | Select the first 5 friends for the third user. 68 | Select the first 5 friends for the fourth user. 69 | Select the first 5 friends for the fifth user. 70 | ``` 71 | 72 | We have an N+1 problem! For every user we are executing a database query. This is noticably inefficient and does not scale. What happens when we have: 73 | 74 | ```graphql 75 | { 76 | users(limit: 5) { 77 | name 78 | friends(limit: 5) { 79 | name 80 | friends(limit: 5) { 81 | name 82 | friends(limit: 5) { 83 | name 84 | } 85 | } 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | This turns into 156 queries! 92 | 93 | The canonical solution to this problem is to use [`dataloader`][] which supposedly implements a pattern that Facebook uses to optimize their GraphQL API in JavaScript. `dataloader` is excellent for batching queries with a simple key. For example this query: 94 | 95 | [`dataloader`]: https://github.com/facebook/dataloader 96 | 97 | ```graphql 98 | { 99 | users(limit: 5) { 100 | name 101 | bestFriend { 102 | name 103 | } 104 | } 105 | } 106 | ``` 107 | 108 | Is easy to optimize this GraphQL query with `dataloader` because assumedly the value we use to fetch the `bestFriend` is a scalar. A simple string identifier for instance. However, when we add arguments into the equation: 109 | 110 | ```graphql 111 | { 112 | users(limit: 5) { 113 | name 114 | friends1: friends(limit: 5) { 115 | name 116 | } 117 | friends2: friends(limit: 5, offset: 5) { 118 | name 119 | } 120 | } 121 | } 122 | ``` 123 | 124 | All of a sudden the keys are not simple scalars. If we wanted to use `dataloader` we might need to use *two* `dataloader` instances. One for `friends(limit: 5)` and one for `friends(limit: 5, offset: 5)` and then on each instance we can use a simple key. An implementation like this can get very complex very quickly and is likely not what you want to spend your time building. 125 | 126 | This package offers an alternative to the `dataloader` batching strategy. This package implements an opinionated batching strategy customized for GraphQL. Instead of batching using a simple key, this package batches by the *GraphQL field*. So for example, let us again look at the following query: 127 | 128 | ```graphql 129 | { 130 | users(limit: 5) { 131 | name 132 | friends(limit: 5) { # Batches 5 executions. 133 | name 134 | friends(limit: 5) { # Batches 25 executions. 135 | name 136 | friends(limit: 5) { # Batches 125 executions. 137 | name 138 | } 139 | } 140 | } 141 | } 142 | } 143 | ``` 144 | 145 | Here we would only have *4* executions instead of 156. One for the root field, one for the first `friends` field, one for the second `friends` field, and so on. This is a powerful alternative to `dataloader` in a case where `dataloader` falls short. 146 | 147 | ## How? 148 | 149 | A batch resolver will run once per GraphQL *field*. So if we assume that you are using a batch resolver on your `friends` field and a frontend engineer writes a query like this: 150 | 151 | ```graphql 152 | { 153 | users(limit: 5) { 154 | name 155 | friends(limit: 5) { 156 | name 157 | friends(limit: 5) { 158 | name 159 | friends(limit: 5) { 160 | name 161 | } 162 | } 163 | } 164 | } 165 | } 166 | ``` 167 | 168 | Every `friends(limit: 5)` field will run exactly one time. How does this work? A GraphQL.js resolver has the following signature: 169 | 170 | ```js 171 | (source, args, context, info) => fieldValue 172 | ``` 173 | 174 | To batch together calls to this function by field, `graphql-resolve-batch` defers the resolution until the next tick while synchronously bucketing `source` values together using the field GraphQL.js AST information from `info`. On the next tick the function you passed into `createBatchResolver` is called with all of the sources that were bucketed in the last tick. 175 | 176 | The implementation is very similar to the `dataloader` implementation. Except `graphql-resolve-batch` takes a more opionated approach to how batching should be implemented in GraphQL whereas `dataloader` is less opionated in how it batches executions together. 177 | 178 | To see how to optimize the above query with a batch resolver, be sure to check out the [**GraphQL.js example**][]. 179 | 180 | [**GraphQL.js example**]: https://github.com/calebmer/graphql-resolve-batch/blob/master/examples/graphql.js 181 | 182 | ## When do I use `dataloader` and when do I use `graphql-resolve-batch`? 183 | 184 | If you answer yes to any of these questions: 185 | 186 | - Do you have a simple primitive key like a string or number that you can use to batch with? 187 | - Do you want to batch requests across your entire schema? 188 | - Do you want to cache data with the same key so that it does not need to be re-requested? 189 | 190 | Use `dataloader`. But for all of the cases where `dataloader` is useful, `graphql-resolve-batch` will likely also be useful. If you find `dataloader` to complex to set up, and its benefits not very attractive you could just use `graphql-resolve-batch` for everywhere you need to hit the database. 191 | 192 | However, if you answer yes to any of these questions: 193 | 194 | - Does your field have arguments? 195 | - Is it hard for you to derive a primitive value from your source values for your field? 196 | - Do you not have the ability to add any new values to `context`? (such as in an embedded GraphQL schema) 197 | 198 | You almost certainly want to use `graphql-resolve-batch`. If you are using `dataloader` then `graphql-resolve-batch` will only be better in a few niche cases. However, `graphql-resolve-batch` is easier to set up. 199 | 200 | ## Credits 201 | 202 | Enjoy efficient GraphQL APIs? Follow the author, [`@calebmer`][] on Twitter for more awesome work like this. 203 | 204 | [`@calebmer`]: http://twitter.com/calebmer 205 | -------------------------------------------------------------------------------- /src/batch.test.js: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import ExampleGraphQLSchema from '../examples/graphql'; 3 | import ExampleGraphQLToolsSchema from '../examples/graphql-tools'; 4 | import { createBatchResolver } from './batch'; 5 | 6 | describe('createBatchResolver', () => { 7 | it('will throw an error if a function is not the first argument', () => { 8 | expect(() => { 9 | createBatchResolver(); 10 | }).toThrow( 11 | 'Expected a function as the first argument when creating a batch ' + 12 | "resolver. Instead found: 'undefined'.", 13 | ); 14 | expect(() => { 15 | createBatchResolver('hello world'); 16 | }).toThrow( 17 | 'Expected a function as the first argument when creating a batch ' + 18 | "resolver. Instead found: 'string'.", 19 | ); 20 | expect(() => { 21 | createBatchResolver(42); 22 | }).toThrow( 23 | 'Expected a function as the first argument when creating a batch ' + 24 | "resolver. Instead found: 'number'.", 25 | ); 26 | expect(() => { 27 | createBatchResolver({}); 28 | }).toThrow( 29 | 'Expected a function as the first argument when creating a batch ' + 30 | "resolver. Instead found: 'object'.", 31 | ); 32 | expect(() => { 33 | createBatchResolver(null, () => {}); 34 | }).toThrow( 35 | 'Expected a function as the first argument when creating a batch ' + 36 | "resolver. Instead found: 'object'.", 37 | ); 38 | }); 39 | 40 | it('will batch basic synchronous resolves grouping with `fieldNodes`', () => { 41 | const batchResolve = jest.fn(sources => 42 | sources.map(source => source + 0.5)); 43 | 44 | const resolve = createBatchResolver(batchResolve); 45 | 46 | const fieldNodes1 = [Symbol('fieldNodes')]; 47 | const fieldNodes2 = [Symbol('fieldNodes')]; 48 | 49 | return Promise.resolve() 50 | .then(() => { 51 | return resolve(0, null, null, { fieldNodes: fieldNodes1 }); 52 | }) 53 | .then(value => { 54 | expect(value).toEqual(0.5); 55 | expect(batchResolve.mock.calls.length).toEqual(1); 56 | return resolve(1, null, null, { fieldNodes: fieldNodes1 }); 57 | }) 58 | .then(value => { 59 | expect(value).toEqual(1.5); 60 | expect(batchResolve.mock.calls.length).toEqual(2); 61 | return resolve(2, null, null, { fieldNodes: fieldNodes1 }); 62 | }) 63 | .then(value => { 64 | expect(value).toEqual(2.5); 65 | expect(batchResolve.mock.calls.length).toEqual(3); 66 | return Promise.all([ 67 | resolve(3, null, null, { fieldNodes: fieldNodes1 }), 68 | resolve(4, null, null, { fieldNodes: fieldNodes1 }), 69 | resolve(5, null, null, { fieldNodes: fieldNodes1 }), 70 | ]); 71 | }) 72 | .then(values => { 73 | expect(values).toEqual([3.5, 4.5, 5.5]); 74 | expect(batchResolve.mock.calls.length).toEqual(4); 75 | return Promise.all([ 76 | resolve(6, null, null, { fieldNodes: fieldNodes1 }), 77 | resolve(7, null, null, { fieldNodes: fieldNodes2 }), 78 | resolve(8, null, null, { fieldNodes: fieldNodes1 }), 79 | resolve(9, null, null, { fieldNodes: fieldNodes1 }), 80 | ]); 81 | }) 82 | .then(values => { 83 | expect(values).toEqual([6.5, 7.5, 8.5, 9.5]); 84 | expect(batchResolve.mock.calls.length).toEqual(6); 85 | expect(batchResolve.mock.calls).toEqual([ 86 | [[0], null, null, { fieldNodes: fieldNodes1 }], 87 | [[1], null, null, { fieldNodes: fieldNodes1 }], 88 | [[2], null, null, { fieldNodes: fieldNodes1 }], 89 | [[3, 4, 5], null, null, { fieldNodes: fieldNodes1 }], 90 | [[6, 8, 9], null, null, { fieldNodes: fieldNodes1 }], 91 | [[7], null, null, { fieldNodes: fieldNodes2 }], 92 | ]); 93 | }); 94 | }); 95 | 96 | it('will pass the first `args` to the batch resolver', () => { 97 | const batchResolve = jest.fn(sources => 98 | sources.map(source => source + 0.5)); 99 | 100 | const resolve = createBatchResolver(batchResolve); 101 | 102 | const fieldNodes = [Symbol('fieldNodes')]; 103 | const args1 = Symbol('args1'); 104 | const args2 = Symbol('args2'); 105 | const args3 = Symbol('args3'); 106 | 107 | return Promise.resolve() 108 | .then(() => { 109 | return Promise.all([ 110 | resolve(0, args1, null, { fieldNodes }), 111 | resolve(1, args2, null, { fieldNodes }), 112 | resolve(2, args3, null, { fieldNodes }), 113 | ]); 114 | }) 115 | .then(values => { 116 | expect(values).toEqual([0.5, 1.5, 2.5]); 117 | expect(batchResolve.mock.calls).toEqual([ 118 | [[0, 1, 2], args1, null, { fieldNodes }], 119 | ]); 120 | }); 121 | }); 122 | 123 | it('will pass the first `context` to the batch resolver', () => { 124 | const batchResolve = jest.fn(sources => 125 | sources.map(source => source + 0.5)); 126 | 127 | const resolve = createBatchResolver(batchResolve); 128 | 129 | const fieldNodes = [Symbol('fieldNodes')]; 130 | const context1 = Symbol('context1'); 131 | const context2 = Symbol('context2'); 132 | const context3 = Symbol('context3'); 133 | 134 | return Promise.resolve() 135 | .then(() => { 136 | return Promise.all([ 137 | resolve(0, null, context1, { fieldNodes }), 138 | resolve(1, null, context2, { fieldNodes }), 139 | resolve(2, null, context3, { fieldNodes }), 140 | ]); 141 | }) 142 | .then(values => { 143 | expect(values).toEqual([0.5, 1.5, 2.5]); 144 | expect(batchResolve.mock.calls).toEqual([ 145 | [[0, 1, 2], null, context1, { fieldNodes }], 146 | ]); 147 | }); 148 | }); 149 | 150 | it( 151 | 'will pass the first `info` to the batch resolver even if `fieldNodes` ' + 152 | 'is the same', 153 | () => { 154 | const batchResolve = jest.fn(sources => 155 | sources.map(source => source + 0.5)); 156 | 157 | const resolve = createBatchResolver(batchResolve); 158 | 159 | const fieldNodes = [Symbol('fieldNodes')]; 160 | const extra1 = Symbol('extra1'); 161 | const extra2 = Symbol('extra2'); 162 | const extra3 = Symbol('extra3'); 163 | 164 | return Promise.resolve() 165 | .then(() => { 166 | return Promise.all([ 167 | resolve(0, null, null, { fieldNodes, extra1 }), 168 | resolve(1, null, null, { fieldNodes, extra2 }), 169 | resolve(2, null, null, { fieldNodes, extra3 }), 170 | ]); 171 | }) 172 | .then(values => { 173 | expect(values).toEqual([0.5, 1.5, 2.5]); 174 | expect(batchResolve.mock.calls).toEqual([ 175 | [[0, 1, 2], null, null, { fieldNodes, extra1 }], 176 | ]); 177 | }); 178 | }, 179 | ); 180 | 181 | it('will reject if an array is not returned by the resolver', () => { 182 | const resolve0 = createBatchResolver(() => null); 183 | const resolve1 = createBatchResolver(() => 42); 184 | const resolve2 = createBatchResolver(() => 'Hello, world!'); 185 | const resolve3 = createBatchResolver(() => ({})); 186 | 187 | const fieldNodes = [Symbol('fieldNodes')]; 188 | 189 | const identity = value => value; 190 | const unexpected = () => { 191 | throw new Error('Unexpected.'); 192 | }; 193 | 194 | return Promise.all([ 195 | resolve0(null, null, null, { fieldNodes }).then(unexpected, identity), 196 | resolve1(null, null, null, { fieldNodes }).then(unexpected, identity), 197 | resolve2(null, null, null, { fieldNodes }).then(unexpected, identity), 198 | resolve3(null, null, null, { fieldNodes }).then(unexpected, identity), 199 | ]) 200 | .then(errors => { 201 | expect(errors.map(({ message }) => message)).toEqual([ 202 | 'Must return an array of values from the batch resolver ' + 203 | "function. Instead the function returned a '[object Null]'.", 204 | 'Must return an array of values from the batch resolver ' + 205 | "function. Instead the function returned a '[object Number]'.", 206 | 'Must return an array of values from the batch resolver ' + 207 | "function. Instead the function returned a '[object String]'.", 208 | 'Must return an array of values from the batch resolver ' + 209 | "function. Instead the function returned a '[object Object]'.", 210 | ]); 211 | }); 212 | }); 213 | 214 | it( 215 | 'will reject if the returned value does not have the same length as ' + 216 | 'the sources', 217 | () => { 218 | const resolve0 = createBatchResolver(() => []); 219 | const resolve1 = createBatchResolver(() => [1]); 220 | const resolve2 = createBatchResolver(() => [1, 2]); 221 | 222 | const fieldNodes = [Symbol('fieldNodes')]; 223 | 224 | const identity = value => value; 225 | const unexpected = () => { 226 | throw new Error('Unexpected.'); 227 | }; 228 | 229 | return Promise.all([ 230 | resolve0(null, null, null, { fieldNodes }).then(unexpected, identity), 231 | resolve1(null, null, null, { fieldNodes }).then(unexpected, identity), 232 | resolve1(null, null, null, { fieldNodes }).then(unexpected, identity), 233 | resolve1(null, null, null, { fieldNodes }).then(unexpected, identity), 234 | resolve2(null, null, null, { fieldNodes }).then(unexpected, identity), 235 | ]) 236 | .then(errors => { 237 | expect(errors.map(({ message }) => message)).toEqual([ 238 | 'Must return the same number of values from the batch resolver ' + 239 | 'as there were sources. Expected 1 value(s) but got 0 value(s).', 240 | 'Must return the same number of values from the batch resolver ' + 241 | 'as there were sources. Expected 3 value(s) but got 1 value(s).', 242 | 'Must return the same number of values from the batch resolver ' + 243 | 'as there were sources. Expected 3 value(s) but got 1 value(s).', 244 | 'Must return the same number of values from the batch resolver ' + 245 | 'as there were sources. Expected 3 value(s) but got 1 value(s).', 246 | 'Must return the same number of values from the batch resolver ' + 247 | 'as there were sources. Expected 1 value(s) but got 2 value(s).', 248 | ]); 249 | expect(errors[1]).not.toBe(errors[0]); 250 | expect(errors[1]).toBe(errors[1]); 251 | expect(errors[1]).toBe(errors[2]); 252 | expect(errors[1]).toBe(errors[3]); 253 | expect(errors[1]).not.toBe(errors[4]); 254 | }); 255 | }, 256 | ); 257 | 258 | it('will reject individual promises if errors are returned', () => { 259 | const error1 = new Error('Yikes 1'); 260 | const error2 = new Error('Yikes 1'); 261 | 262 | const resolve = createBatchResolver(() => [1, error1, 3, 4, error2]); 263 | 264 | const fieldNodes = [Symbol('fieldNodes')]; 265 | 266 | const identity = value => value; 267 | const unexpected = () => { 268 | throw new Error('Unexpected.'); 269 | }; 270 | 271 | return Promise.all([ 272 | resolve(null, null, null, { fieldNodes }).then(identity, unexpected), 273 | resolve(null, null, null, { fieldNodes }).then(unexpected, identity), 274 | resolve(null, null, null, { fieldNodes }).then(identity, unexpected), 275 | resolve(null, null, null, { fieldNodes }).then(identity, unexpected), 276 | resolve(null, null, null, { fieldNodes }).then(unexpected, identity), 277 | ]) 278 | .then(results => { 279 | expect(results).toEqual([1, error1, 3, 4, error2]); 280 | expect(results[1]).toEqual(error1); 281 | expect(results[4]).toEqual(error2); 282 | }); 283 | }); 284 | }); 285 | 286 | /* eslint-disable no-console */ 287 | describe('examples', () => { 288 | const schemas = [ 289 | { name: 'graphql', schema: ExampleGraphQLSchema }, 290 | { name: 'graphql-tools', schema: ExampleGraphQLToolsSchema }, 291 | ]; 292 | 293 | let originalConsoleLog; 294 | 295 | beforeAll(() => { 296 | originalConsoleLog = console.log; 297 | console.log = jest.fn(); 298 | }); 299 | 300 | afterAll(() => { 301 | console.log = originalConsoleLog; 302 | }); 303 | 304 | schemas.forEach(({ name, schema }) => { 305 | describe(name, () => { 306 | it('will call the batch resolver once for every level', async () => { 307 | console.log.mockClear(); 308 | const query = ` 309 | { 310 | user(id: 5) { 311 | friends(limit: 4) { 312 | friends(limit: 3) { 313 | friends(limit: 2) { 314 | friends(limit: 1) { 315 | id 316 | } 317 | } 318 | } 319 | } 320 | } 321 | } 322 | `; 323 | const result = await graphql(schema, query); 324 | expect(console.log).toHaveBeenCalledTimes(4); 325 | expect(result).toEqual({ 326 | data: { 327 | user: { 328 | friends: [ 329 | { 330 | friends: [ 331 | { 332 | friends: [ 333 | { friends: [{ id: 5 }, { id: 6 }] }, 334 | { friends: [{ id: 1 }, { id: 5 }] }, 335 | { friends: [{ id: 5 }, { id: 6 }] }, 336 | ], 337 | }, 338 | { 339 | friends: [ 340 | { friends: [{ id: 5 }, { id: 6 }] }, 341 | { friends: [{ id: 1 }, { id: 6 }] }, 342 | { friends: [{ id: 2 }, { id: 6 }] }, 343 | ], 344 | }, 345 | { 346 | friends: [ 347 | { friends: [{ id: 5 }, { id: 6 }] }, 348 | { friends: [{ id: 7 }, { id: 10 }] }, 349 | { friends: [{ id: 10 }, { id: 14 }] }, 350 | ], 351 | }, 352 | { 353 | friends: [ 354 | { friends: [{ id: 5 }, { id: 6 }] }, 355 | { friends: [{ id: 1 }, { id: 6 }] }, 356 | { friends: [{ id: 2 }, { id: 6 }] }, 357 | ], 358 | }, 359 | ], 360 | }, 361 | { 362 | friends: [ 363 | { 364 | friends: [ 365 | { friends: [{ id: 1 }, { id: 6 }] }, 366 | { friends: [{ id: 1 }, { id: 5 }] }, 367 | { friends: [{ id: 1 }, { id: 2 }] }, 368 | ], 369 | }, 370 | { 371 | friends: [ 372 | { friends: [{ id: 5 }, { id: 6 }] }, 373 | { friends: [{ id: 1 }, { id: 5 }] }, 374 | { friends: [{ id: 5 }, { id: 6 }] }, 375 | ], 376 | }, 377 | { 378 | friends: [ 379 | { friends: [{ id: 7 }, { id: 10 }] }, 380 | { friends: [{ id: 1 }, { id: 5 }] }, 381 | { friends: [{ id: 7 }, { id: 16 }] }, 382 | ], 383 | }, 384 | { 385 | friends: [ 386 | { friends: [{ id: 1 }, { id: 6 }] }, 387 | { friends: [{ id: 1 }, { id: 5 }] }, 388 | { friends: [{ id: 2 }, { id: 6 }] }, 389 | ], 390 | }, 391 | ], 392 | }, 393 | { 394 | friends: [ 395 | { 396 | friends: [ 397 | { friends: [{ id: 5 }, { id: 6 }] }, 398 | { friends: [{ id: 1 }, { id: 5 }] }, 399 | { friends: [{ id: 5 }, { id: 6 }] }, 400 | ], 401 | }, 402 | { 403 | friends: [ 404 | { friends: [{ id: 5 }, { id: 6 }] }, 405 | { friends: [{ id: 1 }, { id: 6 }] }, 406 | { friends: [{ id: 2 }, { id: 6 }] }, 407 | ], 408 | }, 409 | { 410 | friends: [ 411 | { friends: [{ id: 7 }, { id: 10 }] }, 412 | { friends: [{ id: 1 }, { id: 5 }] }, 413 | { friends: [{ id: 7 }, { id: 16 }] }, 414 | ], 415 | }, 416 | { 417 | friends: [ 418 | { friends: [{ id: 5 }, { id: 6 }] }, 419 | { friends: [{ id: 7 }, { id: 10 }] }, 420 | { friends: [{ id: 10 }, { id: 14 }] }, 421 | ], 422 | }, 423 | ], 424 | }, 425 | { 426 | friends: [ 427 | { 428 | friends: [ 429 | { friends: [{ id: 1 }, { id: 6 }] }, 430 | { friends: [{ id: 1 }, { id: 5 }] }, 431 | { friends: [{ id: 1 }, { id: 2 }] }, 432 | ], 433 | }, 434 | { 435 | friends: [ 436 | { friends: [{ id: 5 }, { id: 6 }] }, 437 | { friends: [{ id: 1 }, { id: 5 }] }, 438 | { friends: [{ id: 5 }, { id: 6 }] }, 439 | ], 440 | }, 441 | { 442 | friends: [ 443 | { friends: [{ id: 7 }, { id: 10 }] }, 444 | { friends: [{ id: 1 }, { id: 5 }] }, 445 | { friends: [{ id: 7 }, { id: 16 }] }, 446 | ], 447 | }, 448 | { 449 | friends: [ 450 | { friends: [{ id: 1 }, { id: 5 }] }, 451 | { friends: [{ id: 1 }, { id: 4 }] }, 452 | { friends: [{ id: 1 }, { id: 4 }] }, 453 | ], 454 | }, 455 | ], 456 | }, 457 | { 458 | friends: [ 459 | { 460 | friends: [ 461 | { friends: [{ id: 5 }, { id: 6 }] }, 462 | { friends: [{ id: 1 }, { id: 5 }] }, 463 | { friends: [{ id: 5 }, { id: 6 }] }, 464 | ], 465 | }, 466 | { 467 | friends: [ 468 | { friends: [{ id: 7 }, { id: 10 }] }, 469 | { friends: [{ id: 1 }, { id: 5 }] }, 470 | { friends: [{ id: 7 }, { id: 16 }] }, 471 | ], 472 | }, 473 | { 474 | friends: [ 475 | { friends: [{ id: 1 }, { id: 6 }] }, 476 | { friends: [{ id: 1 }, { id: 5 }] }, 477 | { friends: [{ id: 2 }, { id: 6 }] }, 478 | ], 479 | }, 480 | { 481 | friends: [ 482 | { friends: [{ id: 5 }, { id: 6 }] }, 483 | { friends: [{ id: 7 }, { id: 10 }] }, 484 | { friends: [{ id: 10 }, { id: 14 }] }, 485 | ], 486 | }, 487 | ], 488 | }, 489 | ], 490 | }, 491 | }, 492 | }); 493 | }); 494 | }); 495 | }); 496 | }); 497 | /* eslint-enable no-console */ 498 | --------------------------------------------------------------------------------