DataLoader

repository·main·Indexed 12 days ago

https://github.com/graphql/dataloader

A generic data loading utility for Node.js that provides batching and caching to optimize access to remote data sources like databases or web services. Version 2.2.3. It is commonly used in GraphQL servers to prevent the N+1 query problem by coalescing multiple requests into a single batch call within a single tick of the event loop.

Tokens
8.8K
Snippets
27
Records
31
Agent score
93%

What's inside DataLoader

  1. Create a new DataLoader per request

    main

    To ensure security and data isolation between different users (especially when access permissions vary), it is a recommended pattern to create a new instance of all DataLoaders for every incoming web request.

    A common way to manage this is to create a factory function that returns an object containing all necessary loaders. This object can then be passed through your application logic, for example, as part of the rootValue in a GraphQL request.

    function createLoaders(authToken) {
      return {
        users: new DataLoader(ids => genUsers(authToken, ids)),
        cdnUrls: new DataLoader(rawUrls => genCdnUrls(authToken, rawUrls)),
        stories: new DataLoader(keys => genStories(authToken, keys)),
      };
    }
    
    // When handling an incoming web request:
    const loaders = createLoaders(request.query.authToken);
    
    // Then, within application logic:
    const user = await loaders.users.load(4);
    const pic = await loaders.cdnUrls.load(user.rawPicUrl);
  2. Load by alternative keys and prime the cache

    main

    If a single entity can be accessed via multiple unique keys (e.g., an id and a username), you can use the .prime(key, value) method to keep both loaders' caches in sync. When one loader fetches a user by ID, you can manually add that user to the username loader's cache so subsequent requests by username don't trigger a new batch load.

    const userByIDLoader = new DataLoader(async ids => {
      const users = await genUsersByID(ids);
      for (let user of users) {
        usernameLoader.prime(user.username, user);
      }
      return users;
    });
    
    const usernameLoader = new DataLoader(async names => {
      const users = await genUsernames(names);
      for (let user of users) {
        userByIDLoader.prime(user.id, user);
      }
      return users;
    });
  3. How DataLoader caching works

    main

    DataLoader uses an in-memory memoization cache to prevent redundant loads within a single request. When .load(key) is called, the resulting value is cached. Subsequent calls with the same key return the cached value instead of triggering a new batch load.

    Per-Request Caching Pattern

    DataLoader is designed as a per-request mechanism. To avoid data leakage between different users (e.g., one user seeing another's cached data), you should create a new DataLoader instance at the start of every web request and discard it when the request ends.

    Caching and Batching Interaction

    Even if a key is already cached, its Promise will still wait for the current active batch to complete. This ensures that subsequent dependent loads (e.g., loading a property of a cached object) happen in the same execution tick, maintaining optimization benefits.

    function createLoaders(authToken) {
      return {
        users: new DataLoader(ids => genUsers(authToken, ids)),
      };
    }
    
    const app = express();
    
    app.get('/', function (req, res) {
      const authToken = authenticateUser(req);
      const loaders = createLoaders(authToken);
      res.send(renderPage(req, loaders));
    });
  4. How DataLoader batching works

    main

    DataLoader's primary feature is batching. It allows you to call .load(key) for individual items, but it automatically coalesces all requests made within a single tick of the event loop into a single call to your batch loading function. This reduces the number of round-trips to your data source (e.g., a database or web service).

    Each DataLoader instance maintains its own unique cache. In web servers like Express, it is a best practice to create a new DataLoader instance per request to ensure user isolation and prevent data leaking between different users.

    const DataLoader = require('dataloader');
    
    // Create a loader with a batch function
    const userLoader = new DataLoader(keys => myBatchGetUsers(keys));
    
    // Individual loads are coalesced into a single batch call
    const user = await userLoader.load(1);
    const invitedBy = await userLoader.load(user.invitedByID);
  5. Integrate DataLoader with GraphQL

    main

    DataLoader is highly effective in GraphQL servers to prevent the N+1 query problem. Instead of each field resolver issuing a direct database request, you can use a DataLoader to batch and cache requests. This reduces the number of database round-trips significantly.

    In a GraphQL resolver, instead of fetching data directly, call loader.load(key). This allows the DataLoader to collect all requested keys within a single execution tick and resolve them in a single batch call.

    const UserType = new GraphQLObjectType({
      name: 'User',
      fields: () => ({
        name: { type: GraphQLString },
        bestFriend: {
          type: UserType,
          resolve: user => userLoader.load(user.bestFriendID),
        },
        friends: {
          args: {
            first: { type: GraphQLInt },
          },
          type: new GraphQLList(UserType),
          resolve: async (user, { first }) => {
            const rows = await queryLoader.load([
              'SELECT toID FROM friends WHERE fromID=? LIMIT ?',
              user.id,
              first,
            ]);
            return rows.map(row => userLoader.load(row.toID));
          },
        },
      }),
    });
  6. Implement DataLoader with RethinkDB using getAll

    main

    When using RethinkDB's getAll method with DataLoader, you must handle two specific behaviors of RethinkDB:

    1. Unordered Results: getAll does not guarantee that the returned documents match the order of the requested keys.
    2. Missing Keys: Non-existent keys do not return empty records; they are simply omitted from the result set.

    Because DataLoader requires the returned array to have the exact same length and order as the input keys array, you must normalize the results. The recommended pattern is to index the results into a Map using the index field, and then map the original keys to the values in that map. If a key is missing from the map, you should return an Error object for that specific key to satisfy DataLoader's contract.

    ```js
    const r = require('rethinkdb');
    const db = await r.connect();
    
    // 1. Define an indexing function
    function indexResults(results, indexField, cacheKeyFn = key => key) {
      const indexedResults = new Map();
      results.forEach(res => {
        indexedResults.set(cacheKeyFn(res[indexField]), res);
      });
      return indexedResults;
    }
    
    // 2. Define a normalization function factory
    function normalizeRethinkDbResults(keys, indexField, cacheKeyFn = key => key) {
      return results => {
        const indexedResults = indexResults(results, indexField, cacheKeyFn);
        return keys.map(
          val =>
            indexedResults.get(cacheKeyFn(val)) ||
            new Error(`Key not found : ${val}`),
        );
      };
    }
    
    // 3. Initialize the DataLoader with the normalized batch function
    const exampleLoader = new DataLoader(async keys => {
      const results = await db.table('example_table').getAll(...keys);
      return normalizeRethinkDbResults(results.toArray(), 'id');
    });
    
    // Usage:
    // Returns: [{
  7. How DataLoader batching and caching work

    main

    DataLoader is designed to solve the N+1 query problem through two main mechanisms:

    1. Batching: Instead of executing a request immediately when load() is called, DataLoader collects keys in a 'batch'. It waits for the current execution frame (and the microtask queue) to finish before calling the batchLoadFn once with all collected keys. This allows multiple individual load() calls to be collapsed into a single efficient database or API call.

    2. Caching (Memoization): Each DataLoader instance maintains its own cache. When load(key) is called, the loader first checks if a Promise for that key already exists in the cache. If it does, it returns the existing Promise, preventing redundant work and ensuring that multiple requests for the same key within the same lifecycle return the same result.

    Warning: Because each instance has its own cache, you should typically create a new DataLoader instance per web request (e.g., per GraphQL execution) to avoid leaking data between different users or sessions.

  8. Use DataLoader with Knex.js for SQL batching

    main

    You can use DataLoader to batch SQL queries when using a query builder like Knex.js. Instead of executing multiple individual queries for each ID, you use a whereIn clause to fetch all requested records in a single database round-trip.

    When implementing the batch function, you must ensure that the returned array of results has the same length as the array of input keys and that the order of the results matches the order of the input keys. This is typically achieved by mapping over the original ids array and finding the corresponding record in the database rows.

    const DataLoader = require('dataloader');
    const db = require('./db'); // an instance of Knex client
    
    // The list of data loaders
    const loaders = {
      user: new DataLoader(ids =>
        db
          .table('users')
          .whereIn('id', ids)
          .select()
          .then(rows => ids.map(id => rows.find(x => x.id === id))),
      ),
    
      story: new DataLoader(ids =>
        db
          .table('stories')
          .whereIn('id', ids)
          .select()
          .then(rows => ids.map(id => rows.find(x => x.id === id))),
      ),
    
      storiesByUserId: new DataLoader(ids =>
        db
          .table('stories')
          .whereIn('author_id', ids)
          .select()
          .then(rows => ids.map(id => rows.filter(x => x.author_id === id))),
      ),
    };
    
    // Usage
    const [user, stories] = await Promise.all([
      loaders.user.load('1234'),
      loaders.storiesByUserId.load('1234'),
    ]);
  9. Convert Object-based batch results to Array-based

    main

    DataLoader requires the batch function to return an Array of the same length as the input keys. If your underlying data source returns a keyed object (e.g., { id: user }) instead of an array, use a higher-order function to map the keys to the object values.

    function objResults(batchLoader) {
      return keys =>
        batchLoader(keys).then(objValues =>
          keys.map(key => objValues[key] || new Error(`No value for ${key}`)),
        );
    }
    
    const myLoader = new DataLoader(objResults(myBatchLoader));
  10. Use DataLoader with SQL databases

    main

    DataLoader can be used with SQL databases by leveraging WHERE IN statements to batch multiple requests into a single query.

    When implementing a batch function for SQL, you must ensure that the returned array of results matches the order of the input keys. A common pattern is to map over the original ids array and find the corresponding row in the result set returned by the database. If a row is not found for a specific ID, you should return an Error object for that specific index to notify the caller that the record is missing.

    const DataLoader = require('dataloader');
    const sqlite3 = require('sqlite3');
    
    const db = new sqlite3.Database('./to/your/db.sql');
    
    // Dispatch a WHERE-IN query, ensuring response has rows in correct order.
    const userLoader = new DataLoader(
      ids =>
        new Promise((resolve, reject) => {
          db.all(
            'SELECT * FROM users WHERE id IN $ids',
            { $ids: ids },
            (error, rows) => {
              if (error) {
                reject(error);
              } else {
                resolve(
                  ids.map(
                    id =>
                      rows.find(row => row.id === id) ||
                      new Error(`Row not found: ${id}`),
                  ),
                );
              }
            },
          );
        }),
    );
    
    // Usage
    const promise1 = userLoader.load('1234');
    const promise2 = userLoader.load('5678');
    const [user1, user2] = await Promise.all([promise1, promise2]);
    console.log(user1, user2);
  11. Use DataLoader with Google Datastore

    main

    Since Google Datastore supports batch operations, it is a good candidate for DataLoader. When implementing a loader for Datastore, you must ensure that the array of results returned by your batch function matches the order and length of the input keys. If a key is not found, return null for that index.

    Because Datastore complex keys are objects, you should provide a cacheKeyFn using JSON.stringify to ensure they can be used correctly as cache keys within DataLoader.

    const Datastore = require('@google-cloud/datastore');
    const DataLoader = require('dataloader');
    
    const datastore = new Datastore();
    
    const datastoreLoader = new DataLoader(
      async keys => {
        const results = await datastore.get(keys);
        // Sort resulting entities by the keys they were requested with.
        const entities = results[0];
        const entitiesByKey = {};
        entities.forEach(entity => {
          entitiesByKey[JSON.stringify(entity[datastore.KEY])] = entity;
        });
        return keys.map(key => entitiesByKey[JSON.stringify(key)] || null);
      },
      {
        // Datastore complex keys need to be converted to a string for use as cache keys
        cacheKeyFn: key => JSON.stringify(key),
      },
    );