Skip to content

Commit 1621df9

Browse files
authored
Merge pull request #57 from shawnmcknight/improved-dataloader
Improved DataLoader
2 parents 48ed4c7 + c61784e commit 1621df9

5 files changed

Lines changed: 373 additions & 203 deletions

File tree

README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,63 @@ module.exports = {
258258
};
259259
```
260260

261+
### Dataloader
262+
moleculer-apollo-server supports [DataLoader](https://github.com/graphql/dataloader) via configuration in the resolver definition.
263+
The called action must be compatible with DataLoader semantics -- that is, it must accept params with an array property and return an array of the same size,
264+
with the results in the same order as they were provided.
265+
266+
To activate DataLoader for a resolver, simply add `dataLoader: true` to the resolver's property object in the `resolvers` property of the service's `graphql` property:
267+
268+
```js
269+
settings: {
270+
graphql: {
271+
resolvers: {
272+
Post: {
273+
author: {
274+
action: "users.resolve",
275+
dataLoader: true,
276+
rootParams: {
277+
author: "id",
278+
},
279+
},
280+
voters: {
281+
action: "users.resolve",
282+
dataLoader: true,
283+
rootParams: {
284+
voters: "id",
285+
},
286+
},
287+
...
288+
```
289+
Since DataLoader only expects a single value to be loaded at a time, only one `rootParams` key/value pairing will be utilized, but `params` and GraphQL child arguments work properly.
290+
291+
You can also specify [options](https://github.com/graphql/dataloader#api) for construction of the DataLoader in the called action definition's `graphql` property. This is useful for setting things like `maxBatchSize'.
292+
293+
```js
294+
resolve: {
295+
params: {
296+
id: [{ type: "number" }, { type: "array", items: "number" }],
297+
graphql: { dataLoaderOptions: { maxBatchSize: 100 } },
298+
},
299+
handler(ctx) {
300+
this.logger.debug("resolve action called.", { params: ctx.params });
301+
if (Array.isArray(ctx.params.id)) {
302+
return _.cloneDeep(ctx.params.id.map(id => this.findByID(id)));
303+
} else {
304+
return _.cloneDeep(this.findByID(ctx.params.id));
305+
}
306+
},
307+
},
308+
```
309+
It is unlikely that setting any of the options which accept a function will work properly unless you are running moleculer in a single-node environment. This is because the functions will not serialize and be run by the moleculer-web Api Gateway.
310+
261311
## Examples
262312
263313
- [Simple](examples/simple/index.js)
264314
- `npm run dev`
265315
- [Full](examples/full/index.js)
266316
- `npm run dev full`
267-
- [Full With Dataloader](examples/full-dataloader/index.js)
317+
- [Full With Dataloader](examples/full/index.js)
268318
- set `DATALOADER` environment variable to `"true"`
269319
- `npm run dev full`
270320
# Test

package-lock.json

Lines changed: 13 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,11 @@
6868
"@apollographql/graphql-playground-html": "^1.6.24",
6969
"accept": "^3.1.3",
7070
"apollo-server-core": "^2.9.6",
71-
"dataloader": "^1.4.0",
71+
"dataloader": "^2.0.0",
7272
"graphql-subscriptions": "^1.1.0",
7373
"graphql-tools": "^4.0.5",
7474
"graphql-upload": "^8.1.0",
75-
"lodash": "^4.17.15"
75+
"lodash": "^4.17.15",
76+
"object-hash": "^2.0.1"
7677
}
7778
}

src/service.js

Lines changed: 104 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const DataLoader = require("dataloader");
1313
const { makeExecutableSchema } = require("graphql-tools");
1414
const GraphQL = require("graphql");
1515
const { PubSub, withFilter } = require("graphql-subscriptions");
16+
const hash = require("object-hash");
1617

1718
module.exports = function(mixinOptions) {
1819
mixinOptions = _.defaultsDeep(mixinOptions, {
@@ -127,35 +128,56 @@ module.exports = function(mixinOptions) {
127128
const {
128129
dataLoader = false,
129130
nullIfError = false,
130-
params = {},
131+
params: staticParams = {},
131132
rootParams = {},
132133
} = def;
133134
const rootKeys = Object.keys(rootParams);
134135

135136
return async (root, args, context) => {
136137
try {
137138
if (dataLoader) {
138-
const dataLoaderKey = rootKeys[0]; // use the first root key
139-
const rootValue = root && _.get(root, dataLoaderKey);
139+
const dataLoaderMapKey = this.getDataLoaderMapKey(
140+
actionName,
141+
staticParams,
142+
args
143+
);
144+
const dataLoaderRootKey = rootKeys[0]; // for dataloader, use the first root key only
145+
146+
// check to see if the DataLoader has already been added to the GraphQL context; if not then add it for subsequent use
147+
let dataLoader;
148+
if (context.dataLoaders.has(dataLoaderMapKey)) {
149+
dataLoader = context.dataLoaders.get(dataLoaderMapKey);
150+
} else {
151+
const batchedParamKey = rootParams[dataLoaderRootKey];
152+
dataLoader = this.buildDataLoader(
153+
context.ctx,
154+
actionName,
155+
batchedParamKey,
156+
staticParams,
157+
args
158+
);
159+
context.dataLoaders.set(dataLoaderMapKey, dataLoader);
160+
}
161+
162+
const rootValue = root && _.get(root, dataLoaderRootKey);
140163
if (rootValue == null) {
141164
return null;
142165
}
143166

144167
return Array.isArray(rootValue)
145-
? await Promise.all(
146-
rootValue.map(item =>
147-
context.loaders[actionName].load(item)
148-
)
149-
)
150-
: await context.loaders[actionName].load(rootValue);
168+
? await dataLoader.loadMany(rootValue)
169+
: await dataLoader.load(rootValue);
151170
} else {
152-
const p = {};
171+
const params = {};
153172
if (root && rootKeys) {
154-
rootKeys.forEach(k => _.set(p, def.rootParams[k], _.get(root, k)));
173+
rootKeys.forEach(key =>
174+
_.set(params, rootParams[key], _.get(root, key))
175+
);
155176
}
177+
156178
return await context.ctx.call(
157179
actionName,
158-
_.defaultsDeep(args, p, params)
180+
_.defaultsDeep({}, args, params, staticParams)
159181
);
160182
}
161183
} catch (err) {
@@ -171,6 +193,50 @@ module.exports = function(mixinOptions) {
171193
};
172194
},
173195

196+
/**
197+
* Get the unique key assigned to the DataLoader map
198+
* @param {string} actionName - Fully qualified action name to bind to dataloader
199+
* @param {Object.<string, any>} staticParams - Static parameters to use in dataloader
200+
* @param {Object.<string, any>} args - Arguments passed to GraphQL child resolver
201+
* @returns {string} Key to the dataloader instance
202+
*/
203+
getDataLoaderMapKey(actionName, staticParams, args) {
204+
if (Object.keys(staticParams).length > 0 || Object.keys(args).length > 0) {
205+
// create a unique hash of the static params and the arguments to ensure a unique DataLoader instance
206+
const actionParams = _.defaultsDeep({}, args, staticParams);
207+
const paramsHash = hash(actionParams);
208+
return `${actionName}:${paramsHash}`;
209+
}
210+
211+
// if no static params or arguments are present then the action name can serve as the key
212+
return actionName;
213+
},
214+
215+
/**
216+
* Build a DataLoader instance
217+
*
218+
* @param {Object} ctx - Moleculer context
219+
* @param {string} actionName - Fully qualified action name to bind to dataloader
220+
* @param {string} batchedParamKey - Parameter key to use for loaded values
221+
* @param {Object} staticParams - Static parameters to use in dataloader
222+
* @param {Object} args - Arguments passed to GraphQL child resolver
223+
* @returns {DataLoader} Dataloader instance
224+
*/
225+
buildDataLoader(ctx, actionName, batchedParamKey, staticParams, args) {
226+
const batchLoadFn = keys => {
227+
const rootParams = { [batchedParamKey]: keys };
228+
return ctx.call(actionName, _.defaultsDeep({}, args, rootParams, staticParams));
229+
};
230+
231+
if (this.dataLoaderOptions.has(actionName)) {
232+
// use any specified options assigned to this action
233+
const options = this.dataLoaderOptions.get(actionName);
234+
return new DataLoader(batchLoadFn, options);
235+
}
236+
237+
return new DataLoader(batchLoadFn);
238+
},
239+
174240
/**
175241
* Create resolver for subscription
176242
*
@@ -469,7 +535,7 @@ module.exports = function(mixinOptions) {
469535
ctx: req.$ctx,
470536
service: req.$service,
471537
params: req.$params,
472-
loaders: this.createLoaders(req, services),
538+
dataLoaders: new Map(), // create an empty map to load DataLoader instances into
473539
}
474540
: {
475541
service: connection.$service,
@@ -490,6 +556,8 @@ module.exports = function(mixinOptions) {
490556
this.apolloServer.installSubscriptionHandlers(this.server);
491557
this.graphqlSchema = schema;
492558

559+
this.buildLoaderOptionMap(services); // rebuild the options for DataLoaders
560+
493561
this.shouldUpdateGraphqlSchema = false;
494562

495563
this.broker.broadcast("graphql.schema.updated", {
@@ -502,71 +570,30 @@ module.exports = function(mixinOptions) {
502570
},
503571

504572
/**
505-
* Create the DataLoader instances to be used for batch resolution
506-
* @param {Object} req
573+
* Build a map of options to use with DataLoader
574+
*
507575
* @param {Object[]} services
508-
* @returns {Object.<string, Object>} Key/value pairs of DataLoader instances
576+
* @modifies {this.dataLoaderOptions}
509577
*/
510-
createLoaders(req, services) {
511-
return services.reduce((serviceAccum, service) => {
512-
const serviceName = this.getServiceName(service);
513-
if (!service.settings) {
514-
service.settings = {};
515-
}
516-
const { graphql } = service.settings;
517-
if (graphql && graphql.resolvers) {
518-
const { resolvers } = graphql;
519-
520-
const typeLoaders = Object.values(resolvers).reduce(
521-
(resolverAccum, type) => {
522-
const resolverLoaders = Object.values(type).reduce(
523-
(fieldAccum, resolver) => {
524-
if (_.isPlainObject(resolver)) {
525-
const {
526-
action,
527-
dataLoader = false,
528-
params = {},
529-
rootParams = {},
530-
} = resolver;
531-
const actionParam = Object.values(rootParams)[0]; // use the first root parameter
532-
if (dataLoader && actionParam) {
533-
const resolverActionName = this.getResolverActionName(
534-
serviceName,
535-
action
536-
);
537-
if (fieldAccum[resolverActionName] == null) {
538-
// create a new DataLoader instance
539-
fieldAccum[resolverActionName] = new DataLoader(
540-
keys =>
541-
req.$ctx.call(
542-
resolverActionName,
543-
_.defaultsDeep(
544-
{
545-
[actionParam]: keys,
546-
},
547-
params
548-
)
549-
)
550-
);
551-
}
552-
}
553-
}
554-
555-
return fieldAccum;
556-
},
557-
{}
558-
);
559-
560-
return { ...resolverAccum, ...resolverLoaders };
561-
},
562-
{}
563-
);
564-
565-
serviceAccum = { ...serviceAccum, ...typeLoaders };
566-
}
567-
568-
return serviceAccum;
569-
}, {});
578+
buildLoaderOptionMap(services) {
579+
this.dataLoaderOptions.clear(); // clear map before rebuilding
580+
581+
services.forEach(service => {
582+
Object.values(service.actions).forEach(action => {
583+
const { graphql: graphqlDefinition, name: actionName } = action;
584+
if (graphqlDefinition && graphqlDefinition.dataLoaderOptions) {
585+
const serviceName = this.getServiceName(service);
586+
const fullActionName = this.getResolverActionName(
587+
serviceName,
588+
actionName
589+
);
590+
this.dataLoaderOptions.set(
591+
fullActionName,
592+
graphqlDefinition.dataLoaderOptions
593+
);
594+
}
595+
});
596+
});
570597
},
571598
},
572599

@@ -576,6 +603,7 @@ module.exports = function(mixinOptions) {
576603
this.graphqlSchema = null;
577604
this.pubsub = null;
578605
this.shouldUpdateGraphqlSchema = true;
606+
this.dataLoaderOptions = new Map();
579607

580608
const route = _.defaultsDeep(mixinOptions.routeOptions, {
581609
aliases: {

0 commit comments

Comments
 (0)