Skip to main content

Let's solve (N+1) problem in GraphQL

Greetings!

This is the fourth article of the GraphQL series and the second part of the Query where I am going to solve (N+1) problem in GraphQL. However, I will add complete codes as well to make it usable on its own.

The complete code can be found here.
Code with (N+1) problem unresolved is here.

(N+1) in brief

When we fetch relationships like we did in our movie server where we queried movies, there will be extra N queries to fetch the relationship (director). With the first query to find movies, there are (N+1) queries altogether.
Had you run our example by adding a console.log you can see the problem clearly.
export function findDirector(id) {
  console.log(`find director for {id}`);
  return directors.find(director => director.id === id);
}
This problem does not depend on the database we used. Whether it is a fake store like we use, MySql, or MongoDB, the problem is the same.

Solving N+1

Facebook provides a solution for this using the library data-loader. It collects all the keys first and fetches everything in a single query.

Initializing the project

Let's create a project using npm and install dependencies.
npm init -y
npm install express graphql express-graphql dataloader --save
npm install @babel/core @babel/node @babel/preset-env --save-dev
npm install nodemon --save-dev
package.json with ES6 and babel enabled.
{
  "name": "nodejs-graphql-dataloader",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "start": "nodemon --exec babel-node src/index",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "dataloader": "^2.1.0",
    "express": "^4.18.1",
    "express-graphql": "^0.12.0",
    "graphql": "^16.6.0"
  },
  "devDependencies": {
    "@babel/core": "^7.19.3",
    "@babel/node": "^7.19.1",
    "@babel/preset-env": "^7.19.3",
    "nodemon": "^2.0.20"
  }
}
Create .bablerc and add below to setup babel
touch .babelrc
{
  "presets": ["@babel/preset-env"]
}
Our project structure will be like below;

Movie data store

To keep things simple, we will use a fake database with an array. This is our movie-data-service.js module.
As the dataloader will eventually fetch by keys, let's add a function to our data service to fetch directors by ids.
const movies = [
  { id: "1", title: "The Great Battle", year: 2018, directorId: "2" },
  { id: "2", title: "Baahubali: The Beginning", year: 2015, directorId: "3" },]
  { id: "5", title: "Rurouni Kenshin Part I: Origins", year: 2012, directorId: "4" }
];

const directors = [
  { id: "2", name: "Kwang-shik Kim" },
  { id: "3", name: "S.S. Rajamouli" },
  { id: "4", name: "Keishi Otomo" }
];

export function findMovies() {
  return movies;
}

export function findMovie(id) {
  return movies.find(movie => movie.id === id);
}

export function findMoviesByDirector(directorId) {
  return movies.filter(movie => movie.directorId === directorId);
}

export function findDirector(id) {
  return directors.find(director => director.id === id);
}

export function findDirectorsByIds(ids) {
  return directors.filter(director => ids.includes(director.id));
}

Express server

Create index.js inside the root directory of the project. We are creating an express server here and separating out the graphql setup into a separate module.
import express from "express";
import configureGraphql from "./graphql-schema.js";

const app = express();

configureGraphql(app);

app.listen(3000, () => console.log('Running a GraphQL API server at http://localhost:3000/graphql'));

Time to create a dataloader

Let's create a new module for dataloader in the src folder.
touch batch-loader.js
Import Dataloader from the dataloader and findDirectorsByIds function from the data service.
Then we need to instantiate a new data loader by passing the desired function as a promise.
const dataLoader = new DataLoader(aPromise);
Data loader injects all the keys (ids in our case) into the passed function. We can fetch all the values at once using passed ids. Then we can access relevant data by using the load method from the resolver.
dataLoader.load(key);
Complete batch-loader.js is like this.
import DataLoader from 'dataloader';
import { findDirectorsByIds } from './movie-data-service.js';

async function directorLoaderFn(ids) {
  const directors = findDirectorsByIds(ids);
  const directorsMap = new Map(directors.map(director => [director.id, director]));
  return ids.map(id => directorsMap.get(id));
}

const directorLoader = new DataLoader(directorLoaderFn);

export { directorLoader };
This is an excellent idea by Facebook and will take time to turn the head over it.

Let's use the dataloader

Obviously, import the directorLoader first in our schema module.
import { directorLoader } from './batch-loader.js';
When we configure GraphQL, we can ingest loaders into the context property.
context: { loaders }
function configureGraphql(app) {
  const loaders = {
    directorLoader
  };

  app.use('/graphql', graphqlHTTP({
    schema: schema,
    graphiql: true,
    context: { loaders }
  }));
}
Then, this will be passed into resolvers by the framework.
resolve: (parent, args, { loaders }) => {}
As all directors are loaded at once and cached in the loader, this will not have other queries. You can test that by adding console logs or by debugging.
resolve: (parent, args, { loaders }) => {}
resolve: (parent, args, { loaders }) => {
  return loaders.directorLoader.load(parent.directorId);
}
const MovieType = new GraphQLObjectType({
  name: 'MovieType',
  fields: () => ({
    id: { type: GraphQLString },
    title: { type: GraphQLString },
    year: { type: GraphQLInt },
    director: {
      type: DirectorType,
      resolve: (parent, args, { loaders }) => {
        return loaders.directorLoader.load(parent.directorId);
      }
    }
  })
});
As all the directors are loaded and cached in the loader, there will be only one query. The full code is;
import { graphqlHTTP } from "express-graphql";
import graphql from 'graphql';
const { GraphQLObjectType, GraphQLString, GraphQLInt, GraphQLList, GraphQLSchema } = graphql;
import { findMovie, findDirector, findMoviesByDirector, findMovies } from './movie-data-service.js';
import { directorLoader } from './batch-loader.js';

const MovieType = new GraphQLObjectType({
  name: 'MovieType',
  fields: () => ({
    id: { type: GraphQLString },
    title: { type: GraphQLString },
    year: { type: GraphQLInt },
    director: {
      type: DirectorType,
      resolve: (parent, args, { loaders }) => {
        return loaders.directorLoader.load(parent.directorId);
      }
    }
  })
});

const DirectorType = new GraphQLObjectType({
  name: 'DirectorType',
  fields: () => ({
    id: { type: GraphQLString },
    name: { type: GraphQLString },
    movies: {
      type: new GraphQLList(MovieType),
      resolve: (parent) => {
        return findMoviesByDirector(parent.id);
      }
    }
  })
});

const RootQueryType = new GraphQLObjectType({
  name: 'RootQueryType',
  fields: {
    movie: {
      type: MovieType,
      args: { id: { type: GraphQLString }},
      resolve: (parent, args) => {
        return findMovie(args.id);
      }
    },
    director: {
      type: DirectorType,
      args: { id: { type: GraphQLString }},
      resolve: (parent, args) => {
        return findDirector(args.id);
      }
    },
    movies: {
      type: new GraphQLList(MovieType),
      resolve: () => {
        return findMovies();
      }
    }
  }
});

const schema = new GraphQLSchema({ query: RootQueryType });

function configureGraphql(app) {
  const loaders = {
    directorLoader
  };

  app.use('/graphql', graphqlHTTP({
    schema: schema,
    graphiql: true,
    context: { loaders }
  }));
}

export default configureGraphql;

Demo

npm start
We can access our application in the browser at http://localhost:3000/graphiql. Try adding logs/debugging whether this has a performance issue.

Conclusion

It is a somewhat long article but anyway it is fun. As you can see, even though GraphQL is easy for the consumer, it makes it hard for developers.

That's it! Happy learning guys☺

https://graphql.org/graphql-js/
https://graphql.org/graphql-js/constructing-types/

Comments