Sejak dirilis pada tahun 2015 lalu, GraphQL yang dikembangkan oleh Facebook digadang-gadang akan menjadi teknologi paling mutakhir yang akan menggantikan peran REST API untuk berkomunikasi antara client dan server. Menurut dokumentasi resminya, GraphQL adalah query language untuk API dan runtime untuk memenuhi request dengan deskripsi dan dokumentasi data yang lengkap dan mudah dimengerti. Memberikan client kebebasan untuk hanya mengambil data yang benar-benar dibutuhkan, membuatnya lebih mudah untuk mengembangkan API dari waktu ke waktu, membuatnya menjadi development tools yang powerful.

Dataloader sendiri, menurut dokumentasi resminya, disebut sebagai library yang menjadi layer pengambilan data untuk menyediakan API yang disederhanakan dan konsisten melalui berbagai remote data source seperti database atau layanan web melalui batching dan caching. Dataloader juga merupakan library yang dikembangkan oleh Facebook. Meskipun dapat digunakan dalam kondisi lain, Dataloader umumnya digunakan untuk menangani masalah N+1 pada GraphQL.

Setelah beberapa saat penggunaan, kami menyadari bahwa Dataloader tidak memiliki built-in API untuk pagination. Lalu, bagaimana kita menyelesaikan masalah ini? Apakah kita benar-benar membutuhkan pagination?

Let’s hack!

ERD

Kita akan membuat sebuah platform e-commerce dimana setiap merchants akan mempunyai satu locations (one-to-one) dan akan mempunyai banyak products (one-to-many). Skema ERD dapat dilihat sebagai berikut.

GraphQL Schema

Setelah mendefinisikan ERD, selanjutnya, kita akan mendefinisikan skema. Berikut adalah desain skema GraphQL sebelumnya (tanpa pagination).

#typeDefs.graphql

type Query{
  allMerchants: [Merchant!]!
}

type Merchant {
  id: ID!
  name: String!
  products: [Product!]!
  location: Location!
}

type Product{
  id: ID!
  name: String!
}

type Location {
  id: ID!
  latitude: Float!
  longitude: Float!
}

GraphQL Resolver

Dan juga, resolver-nya (masih tanpa pagination).

// resolvers.ts

import Knex from 'knex'
import { IGraphQLSchemaContext, Merchant } from '../../types'

export default {
  Query: {
    allMerchants() {
      return Knex(config.get('database'))('merchants')
    },
  },
  Merchant: {
    products({ id: merchantId }: Merchant, _: Record<string, any>, { loaders }: IGraphQLSchemaContext) {
      return loaders.getProductsByMerchantId.load(merchantId)
    },
    location({ id: merchantId }: Merchant, _: Record<string, any>, { loaders }: IGraphQLSchemaContext) {
      return loaders.getLocationsByMerchantId.load(merchantId)
    },
  },
}

GraphQL Instance dan Dataloader

Untuk meng-init instance Dataloader pada setiap permintaan dan juga untuk kenyamanan, kami menempatkannya dalam GraphQL context.

// graphql.ts

import 'graphql-import-node'
import { makeExecutableSchema } from 'graphql-tools'
import { ApolloServer } from 'apollo-server-express'
import Knex from 'knex'
import Dataloader from 'dataloader'
import config from 'config'
import resolvers from './resolvers'
import * as typeDefs from './typeDefs.graphql'

const loaders = () => ({
  getLocationsByMerchantId: new Dataloader(async merchantIds => {
    const locations = await Knex(config.get('database'))('locations').whereIn('merchantId', merchantIds)
    return merchantIds.map(merchantId => locations.find(location => location.merchantId === merchantId))
  }),
  getProductsByMerchantId: new Dataloader(async merchantIds => {
    const products = await Knex(config.get('database'))('products').whereIn('merchantId', merchantIds)
    return merchantIds.map(merchantId => products.filter(product => product.merchantId === merchantId))
  }),
})

const graphQLServer = new ApolloServer({
  schema: makeExecutableSchema({
    typeDefs: [typeDefs],
    resolvers,
  }),
  context: ({ req }) => ({
    currentUser: req.currentUser, // handled by middleware
    loaders: loaders(),
  }),
})

export default graphQLServer

Potongan kode diatas adalah contoh umum pengunaan Dataloader. Dimana kita mempunyai relasi skema dimana locations dan products mengembalikan semua data yang terkait dengan merchants.

Masalah muncul ketika sebuah merchant memiliki banyak products. Misalkan merchant A memiliki 1000 produk. Mengembalikan 1000 products sekaligus bukanlah ide yang bagus. Selain bermasalah dengan konsumsi memori, bandwidth yang akan dikeluarkan juga akan lebih besar.

Solusinya? Pagination!

Pagination adalah teknik yang umum digunakan untuk menangani masalah data yang banyak. Dan ya, kami membutuhkannya untuk bekerja dengan Dataloader. Umumnya, untuk menggunakan pagination, client harus mengirimkan beberapa variabel pendukung seperti limit, offset, orderBy, sortBy, dan search. Mari kita implementasikan!

#typeDefs.graphql

type Query{
  allMerchants: [Merchant!]!
}

type Merchant {
  id: ID!
  name: String!
  products(pagination: PaginationInput!): [Product!]!
  location: Location!
}

type Product{
  id: ID!
  name: String!
}

type Location {
  id: ID!
  latitude: Float!
  longitude: Float!
}

enum SortByEnum {
    ASC,
    DESC
}

input PaginationInput {
  limit: Int = 10
  offset: Int = 0
  orderBy: String = "name"
  sortBy: SortByEnum = "ASC"
  search: String
}

Setelah menentukan variable input pagination, kita harus meneruskan variable-variable tersebut ke Dataloader. Kita bisa melakukan ini sebagaimana passing parameter pada umumnya. Namun, menurut saya, hal ini akan membuat kode tampak “berantakan” dan melanggar prinsip penggunaan argumen di Dataloader. Jadi, alih-alih memanggil instance Dataloader, kami memanggil fungsi yang memiliki parameter pagination dengan mengembalikan instance Dataloader.

Tantangan berikutnya adalah bagaimana kita melakukan satu query ke database dan mendapatkan semua data yang kita butuhkan. Apakah kita membutuhkan library tambahan? Tidak. Kita hanya perlu menggunakan query yang tidak umum digunakan yaitu UNION. UNION sangat cocok untuk mengambil data yang beririsan dengan hanya menampilkan data unik sehingga dapat mengurangi konsumsi memori. Contoh penggunaan union di Knex adalah sebagai berikut.

// graphql.ts

// ...other codes

enum PaginationSortByEnum {
  ASC = 'asc',
  DESC = 'desc',
}

interface IPagination {
  limit: number
  offset: number
  orderBy: string
  sortBy: PaginationSortByEnum
  search: string
}

const loaders = () => {
  let productsPagination: IPagination
  const productsByMerchantIdLoader = new Dataloader(async merchantIds => {
    const connection = Knex(config.get('database'))
    const table = connection('products')
    const { limit, offset, orderBy, sortBy, search } = productsPagination
    if (search) table.where('name', 'LIKE', `%${search}%`)
    const products = await connection.union(
      merchantIds.map(id =>
        table
          .where('merchantId', id)
          .orderBy(orderBy, sortBy)
          .limit(limit)
          .offset(offset)
      ),
      true //option for wrap queries
    )
    return merchantIds.map(merchantId => products.filter(product => product.merchantId === merchantId))
  })

  return {
    getLocationsByMerchantId: new Dataloader(async merchantIds => {
      const locations = await Knex(config.get('database'))('locations').whereIn('merchantId', merchantIds)
      return merchantIds.map(merchantId => locations.find(location => location.merchantId === merchantId))
    }),
    getProductsByMerchantId(pagination: IPagination) {
      productsPagination = pagination
      return productsByMerchantIdLoader
    },
  }
}

// ...other codes

Kemudian, kita juga perlu sedikit mengubah resolver-nya seperti dibawah.

// resolvers.ts

// ...other codes

export default {
  // ...other codes
  Merchant: {
    products(
      { id: merchantId }: Merchant,
      { pagination: input }: { pagination: IPagination },
      { loaders }: IGraphQLSchemaContext
    ) {
      const paginationCollector = {
        limit: input?.limit || 10,
        offset: input?.offset || 0,
        sortBy: input?.sortBy || PaginationSortByEnum.ASC,
        orderBy: input?.orderBy || 'createdAt',
        search: input?.search || '',
      }
      return loaders.getProductsByMerchantId(paginationCollector).load(merchantId)
    },
    // ...other codes
  },
}

Untuk memahami cara kerja loader ini, lihat ilustrasi di bawah ini.

>> merchantIds = ["0713c0b4-a068-4909-b851-a4fcdad4d111", "3ec5d4f6-1459-4fcc-990a-3791bad34843"]

>> (SELECT * FROM products WHERE `merchantId` = "0713c0b4-a068-4909-b851-a4fcdad4d111" ORDER BY `name` ASC LIMIT 0,10) UNION (SELECT * FROM products WHERE `merchantId` = "3ec5d4f6-1459-4fcc-990a-3791bad34843" ORDER BY `name` ASC LIMIT 0,10)

<< [{"id":"98bfa6a5-5558-4d21-9c6c-db5d6267acc4","name":"Kecap Bango 400ml","merchantId":"0713c0b4-a068-4909-b851-a4fcdad4d111"}, ..., {"id":"9d44ca99-6f87-41c0-bf70-bc50db3d2707","name":"Apple iPhone 11 Pro","merchantId":"3ec5d4f6-1459-4fcc-990a-3791bad34843"}, ...]

Dan ya, hanya itu saja. Sekarang kita bisa memberi nomor pada bidang produk alih-alih mengembalikan semua datanya. Bagaimana? Mudah kan?

Demikian permasalahan dan solusi yang saya bisa bagikan. Semoga artikel ini dapat membantu Anda jika menghadapi masalah yang sama. Apabila ada kesalahan, jangan sungkan untuk menulisnya di kolom komentar.

Terima kasih.

Referensi

  1. GraphQL Apollo Server Tutorial
  2. The GraphQL Dataloader Pattern: Visualized
  3. SQL Union overview, usage, and examples
  4. Knex.js — A SQL Query Builder for Javascript

Originally posted in Medium Engineering Efishery