diff --git a/README.md b/README.md index 91d913da..ce8bdebb 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # fastify-gql -[![Greenkeeper badge](https://badges.greenkeeper.io/mcollina/fastify-gql.svg)](https://greenkeeper.io/) +[![Greenkeeper badge](https://badges.greenkeeper.io/mcollina/fastify-gql.svg)](https://greenkeeper.io/) [![Build Status](https://travis-ci.com/mcollina/fastify-gql.svg?branch=master)](https://travis-ci.com/mcollina/fastify-gql) Fastify barebone GraphQL adapter. -Queries are cached on reuse to reduce the overhead of query parsing and -validation. -fastify-gql supports a Just-In-Time compiler via -[graphql-jit](http://npm.im/graphql-jit). + +Features: + +* Caching of query parsing and validation. +* Automatic loader integration to avoid 1 + N queries. +* Just-In-Time compiler via [graphql-jit](http://npm.im/graphql-jit). ## Install @@ -96,6 +98,8 @@ __fastify-gql__ supports the following options: definition](https://graphql.org/graphql-js/type/#graphqlschema). The graphql schema. The string will be parsed. * `resolvers`: Object. The graphql resolvers. +* `loaders`: Object. See [defineLoaders](#defineLoaders) for more + details. * `graphiql`: boolean. Serve [GraphiQL](https://www.npmjs.com/package/graphiql) on `/graphiql` if `routes` is `true`. @@ -248,6 +252,70 @@ async function run () { run() ``` + +#### app.graphql.defineLoaders(loaders) + +A loader is an utility to avoid the 1 + N query problem of GraphQL. +Each defined loader will register a resolver that coalesces each of the +request and combines them into a single, bulk query. Morever, it can +also cache the results, so that other parts of the GraphQL do not have +to fetch the same data. + +Each loader function has the signature `loader(queries, context)`. +`queries` is an array of objects defined as `{ obj, params }` where +`obj` is the current object and `params` are the GraphQL params (those +are the first two parameters of a normal resolver). The `context` is the +GraphQL context, and it includes a `reply` object. + +Example: + + +```js +const loaders = { + Dog: { + async owner (queries, { reply }) { + return queries.map(({ obj }) => owners[obj.name]) + } + } +} + +app.register(GQL, { + schema, + resolvers, + loaders +}) +``` + +It is also possible disable caching with: + +```js +const loaders = { + Dog: { + owner: { + async loader (queries, { reply }) { + return queries.map(({ obj }) => owners[obj.name]) + }, + opts: { + cache: false + } + } + } +} + +app.register(GQL, { + schema, + resolvers, + loaders +}) +``` + +Disabling caching has the advantage to avoid the serialization at +the cost of more objects to fetch in the resolvers. + + +Internally, it uses +[single-user-cache](http://npm.im/single-user-cache). + #### reply.graphql(source, context, variables, operationName) Decorate [Reply](https://www.fastify.io/docs/latest/Reply/) with a diff --git a/example.js b/examples/basic.js similarity index 94% rename from example.js rename to examples/basic.js index de8d6245..005b2ab1 100644 --- a/example.js +++ b/examples/basic.js @@ -1,7 +1,7 @@ 'use strict' const Fastify = require('fastify') -const GQL = require('.') +const GQL = require('..') const app = Fastify() diff --git a/examples/loaders.js b/examples/loaders.js new file mode 100644 index 00000000..c3b66337 --- /dev/null +++ b/examples/loaders.js @@ -0,0 +1,68 @@ +'use strict' + +const Fastify = require('fastify') +const GQL = require('..') + +const app = Fastify() + +const dogs = [{ + name: 'Max' +}, { + name: 'Charlie' +}, { + name: 'Buddy' +}, { + name: 'Max' +}] + +const owners = { + 'Max': { + name: 'Jennifer' + }, + 'Charlie': { + name: 'Sarah' + }, + 'Buddy': { + name: 'Tracy' + } +} + +const schema = ` + type Human { + name: String! + } + + type Dog { + name: String! + owner: Human + } + + type Query { + dogs: [Dog] + } +` + +const resolvers = { + Query: { + dogs (_, params, { reply }) { + return dogs + } + } +} + +const loaders = { + Dog: { + async owner (queries, { reply }) { + return queries.map(({ obj }) => owners[obj.name]) + } + } +} + +app.register(GQL, { + schema, + resolvers, + loaders, + graphiql: true +}) + +app.listen(3000) diff --git a/index.js b/index.js index 6b95f55b..0eab886a 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,7 @@ const LRU = require('tiny-lru') const routes = require('./routes') const { BadRequest, MethodNotAllowed, InternalServerError } = require('http-errors') const { compileQuery } = require('graphql-jit') +const { Factory } = require('single-user-cache') const { parse, buildSchema, @@ -21,6 +22,8 @@ const { execute } = require('graphql') +const kLoaders = Symbol('fastify-gql.loaders') + function buildCache (opts) { if (opts.hasOwnProperty('cache')) { if (opts.cache === false) { @@ -132,10 +135,53 @@ module.exports = fp(async function (app, opts) { } } + let factory + + fastifyGraphQl.defineLoaders = function (loaders) { + // set up the loaders factory + if (!factory) { + factory = new Factory() + app.decorateReply(kLoaders) + app.addHook('onRequest', async function (req, reply) { + reply[kLoaders] = factory.create({ req, reply }) + }) + } + + function defineLoader (name) { + // async needed because of throw + return async function (obj, params, { reply }) { + if (!reply) { + throw new Error('loaders only work via reply.graphql()') + } + return reply[kLoaders][name]({ obj, params }) + } + } + + const resolvers = {} + for (const typeKey of Object.keys(loaders)) { + const type = loaders[typeKey] + resolvers[typeKey] = {} + for (const prop of Object.keys(type)) { + const name = typeKey + '-' + prop + resolvers[typeKey][prop] = defineLoader(name) + if (typeof type[prop] === 'function') { + factory.add(name, type[prop]) + } else { + factory.add(name, type[prop].opts, type[prop].loader) + } + } + } + fastifyGraphQl.defineResolvers(resolvers) + } + if (opts.resolvers) { fastifyGraphQl.defineResolvers(opts.resolvers) } + if (opts.loaders) { + fastifyGraphQl.defineLoaders(opts.loaders) + } + async function fastifyGraphQl (source, context, variables, operationName) { context = Object.assign({ app: this }, context) const reply = context.reply diff --git a/package.json b/package.json index cdac2beb..626fd952 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "graphql": "^14.1.1", "graphql-jit": "0.1.2", "http-errors": "^1.7.2", + "single-user-cache": "^0.3.0", "tiny-lru": "^6.0.0" } } diff --git a/test/loaders.js b/test/loaders.js new file mode 100644 index 00000000..c25cdc62 --- /dev/null +++ b/test/loaders.js @@ -0,0 +1,264 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('..') + +const dogs = [{ + name: 'Max' +}, { + name: 'Charlie' +}, { + name: 'Buddy' +}, { + name: 'Max' +}] + +const owners = { + 'Max': { + name: 'Jennifer' + }, + 'Charlie': { + name: 'Sarah' + }, + 'Buddy': { + name: 'Tracy' + } +} + +const schema = ` + type Human { + name: String! + } + + type Dog { + name: String! + owner: Human + } + + type Query { + dogs: [Dog] + } +` + +const resolvers = { + Query: { + dogs (_, params, { reply }) { + return dogs + } + } +} + +const query = `{ + dogs { + name, + owner { + name + } + } +}` + +test('loaders create batching resolvers', async (t) => { + const app = Fastify() + + const loaders = { + Dog: { + async owner (queries, { reply }) { + // note that the second entry for max is cached + t.deepEqual(queries, [{ + obj: { + name: 'Max' + }, + params: {} + }, { + obj: { + name: 'Charlie' + }, + params: {} + }, { + obj: { + name: 'Buddy' + }, + params: {} + }]) + return queries.map(({ obj }) => owners[obj.name]) + } + } + } + + app.register(GQL, { + schema, + resolvers, + loaders + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + body: { + query + } + }) + + t.equal(res.statusCode, 200) + t.deepEqual(JSON.parse(res.body), { + data: { + dogs: [{ + name: 'Max', + owner: { + name: 'Jennifer' + } + }, { + name: 'Charlie', + owner: { + name: 'Sarah' + } + }, { + name: 'Buddy', + owner: { + name: 'Tracy' + } + }, { + name: 'Max', + owner: { + name: 'Jennifer' + } + }] + } + }) +}) + +test('disable cache for each loader', async (t) => { + const app = Fastify() + + const loaders = { + Dog: { + owner: { + async loader (queries, { reply }) { + // note that the second entry for max is NOT cached + t.deepEqual(queries, [{ + obj: { + name: 'Max' + }, + params: {} + }, { + obj: { + name: 'Charlie' + }, + params: {} + }, { + obj: { + name: 'Buddy' + }, + params: {} + }, { + obj: { + name: 'Max' + }, + params: {} + }]) + return queries.map(({ obj }) => owners[obj.name]) + }, + opts: { + cache: false + } + } + } + } + + app.register(GQL, { + schema, + resolvers, + loaders + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + body: { + query + } + }) + + t.equal(res.statusCode, 200) + t.deepEqual(JSON.parse(res.body), { + data: { + dogs: [{ + name: 'Max', + owner: { + name: 'Jennifer' + } + }, { + name: 'Charlie', + owner: { + name: 'Sarah' + } + }, { + name: 'Buddy', + owner: { + name: 'Tracy' + } + }, { + name: 'Max', + owner: { + name: 'Jennifer' + } + }] + } + }) +}) + +test('defineLoaders method', async (t) => { + const app = Fastify() + + const loaders = { + Dog: { + async owner (queries, { reply }) { + return queries.map(({ obj }) => owners[obj.name]) + } + } + } + + app.register(GQL, { + schema, + resolvers + }) + app.register(async function (app) { + app.graphql.defineLoaders(loaders) + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + body: { + query + } + }) + + t.equal(res.statusCode, 200) + t.deepEqual(JSON.parse(res.body), { + data: { + dogs: [{ + name: 'Max', + owner: { + name: 'Jennifer' + } + }, { + name: 'Charlie', + owner: { + name: 'Sarah' + } + }, { + name: 'Buddy', + owner: { + name: 'Tracy' + } + }, { + name: 'Max', + owner: { + name: 'Jennifer' + } + }] + } + }) +})