Querying a database with GraphQL and DataLoader, an introduction in Go.

The Database

You can use a database of your choice for this tutorial. In the linked Github repository, a Dockerized Postgres database is used as an easy and disposable solution. The database access code is pretty standard and straightforward, so we will skip listing it here. But here is the Dockerfile for our database:

FROM postgresENV POSTGRES_USER store
ENV POSTGRES_PASSWORD p
ENV POSTGRES_DB store
COPY init.sql /docker-entrypoint-initdb.d
Fig. 1. Schema of the store database
docker build -t store .
docker run --rm -p 5432:5432 store

The GraphQL library

There are many different GraphQL libraries for Go. Here, we are going to use one by graph-gophers.

GraphQL

In GraphQL, resolvers are responsible for returning, or resolving, object data. For example, an order resolver would resolve order properties like order ID or time of order, by returning their corresponding simple-type values, an integer and a time value. It would also resolve customer that made the order, which could be represented as a complex type that encapsulates customer’s ID, name, address, etc. In this case, the returned value would be another resolver, customer resolver. The list of products in the order could be resolved by yet another resolver. Nesting like that, results in a directed acyclic graph of resolvers, with each of them responsible for providing a part of a potentially larger object.

Fig. 2. Directed acyclic graph of resolvers

The Schema

Schema is a way of describing the object model and operations supported by your GraphQL service in a strongly-typed fashion. Queries received by the service are validated and execute against the schema. Additionally, there is an out-of-the-box support for introspection of the API of the service, which could be leveraged either by querying schema-specific fields with your own code, https://graphql.org/learn/introspection, or by using tools such as GraphiQL (note a subtle “i” in the name).

scalar Timeschema {
query: Query
}
type Query {
orders(first: Int!): [Order!]!,
}
type Order {
id: Int!,
customer: Customer!,
time: Time!,
products: [OrderProduct!]!,
}
type Customer {
id: Int!,
name: String!,
}
type OrderProduct {
id: Int!,
name: String!,
price: Float!,
quantity: Int!,
totalPrice: Float!,
}

The Resolvers

On to resolvers now. We will start from the root.

Query

This is the entry point to the orders query we have defined in the schema, as well as all other future queries we might add. db.Client is a client of the database storing order data.

package resolvertype Query struct {
c *db.Client
}
func (r *Query) Orders(ctx context.Context, args struct {
First int32
}) ([]*OrderResolver, error) {
orders, err := r.c.GetOrders(ctx, args.First)
if err != nil {
return nil, err
}
res := make([]*OrderResolver, 0, len(orders))
for _, o := range orders {
res = append(res, &OrderResolver{c: r.c, order: o})
}
return res, nil
}

OrderResolver

Now let’s see what OrderResolver looks like.

package resolver// OrderResolver resolves order properties
type OrderResolver struct {
c *db.Client
order *model.Order
}
// ID resolves order ID
func (r *OrderResolver) ID() int32 {
return r.order.ID
}
// Time resolves order time
func (r *OrderResolver) Time() graphql.Time {
return graphql.Time{Time: r.order.Time}
}
// Customer returns customer resolver
func (r *OrderResolver) Customer(ctx context.Context) (*CustomerResolver, error) {
cust, err := r.c.GetCustomer(ctx, r.order.CustomerID)
if err != nil {
return nil, err
}
return &CustomerResolver{cust: cust}, nil
}
// Products returns product resolvers
func (r *OrderResolver) Products(ctx context.Context) ([]*OrderProductResolver, error) {

// get products for the order and return a slice of `OrderProductResolver`s
...
}

DataLoader

To optimize the database querying approach, we will use DataLoader, a utility that provides batching and caching capabilities to application’s data-fetching layer. Returning to the previous paragraph’s example of three orders, the goal here is to get all three customer database records at once instead of doing it one by one.

package dbfunc (c *Client) GetCustomer(ctx context.Context, customerID int32) (*model.Customer, error) {
...
}
package dbfunc (c *Client) GetCustomers(ctx context.Context, customerIDs []int32) ([]*model.Customer, error) {
...
}
package loadertype customerLoader struct {
c *db.Client
}
func (l *customerLoader) loadBatch(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
n := len(keys)
ids, err := ints(keys)
if err != nil {
return loadBatchError(err, n)
}
cc, err := l.c.GetCustomers(ctx, ids)
if err != nil {
return loadBatchError(err, n)
}
res := make([]*dataloader.Result, n)
for _, c := range cc {
// results must be in the same order as keys
i := mustIndex(ids, c.ID)
res[i] = &dataloader.Result{Data: c}
}
return res
}
package loadertype contextKey stringconst (
customerLoaderKey contextKey = "customer"
orderProductLoaderKey contextKey = "orderProduct"
)
// Init initializes and returns Map
func Init(c *db.Client) Map {
return Map{
customerLoaderKey: (&customerLoader{c}).loadBatch,
// orderProductLoaderKey: ... provided as an example
}
}
// Map maps loader keys to batch-load funcs
type Map map[contextKey]dataloader.BatchFunc
// Attach attaches dataloaders to the request's context
func (m Map) Attach(ctx context.Context) context.Context {
for k, batchFunc := range m {
ctx = context.WithValue(ctx, k, dataloader.NewBatchedLoader(batchFunc))
}
return ctx
}
package loader// LoadCustomer loads customer via dataloader
func LoadCustomer(ctx context.Context, id int32) (*model.Customer, error) {
ldr, err := extract(ctx, customerLoaderKey)
if err != nil {
return nil, err
}
v, err := ldr.Load(ctx, key(id))()
if err != nil {
return nil, err
}
res, ok := v.(*model.Customer)
if !ok {
return nil, fmt.Errorf("wrong type: %T", v)
}
return res, nil
}
func extract(ctx context.Context, k contextKey) (*dataloader.Loader, error) {
res, ok := ctx.Value(k).(*dataloader.Loader)
if !ok {
return nil, fmt.Errorf("cannot find a loader: %s", k)
}
return res, nil
}
package resolver// Customer returns customer resolver
func (r *OrderResolver) Customer(ctx context.Context) (*CustomerResolver, error) {
cust, err := loader.LoadCustomer(ctx, r.order.CustomerID)
if err != nil {
return nil, err
}
return &CustomerResolver{cust: cust}, nil
}

The Result

A GraphQL service always has a single API endpoint which, by convention, is available at /graphql, e.g., http://localhost:8080/graphql.

{
"query": "{
orders(first: 2) {
id,
time,
}
}"
}
{
"data": {
"orders": [
{
"id": 2,
"time": "2019-06-26T09:40:39.656311Z"
},
{
"id": 4,
"time": "2019-06-29T09:40:39.6591Z"
}
]
}
}
{
"query": "{
orders(first: 2) {
id,
time,
customer {
name,
},
}
}"
}
{
"data": {
"orders": [
{
"customer": {
"name": "andrew"
},
"id": 2,
"time": "2019-06-26T09:40:39.656311Z"
},
{
"customer": {
"name": "max"
},
"id": 4,
"time": "2019-06-29T09:40:39.6591Z"
}
]
}
}
{
"query": "{
orders(first: 2) {
id,
time,
customer {
name,
},
products {
name,
quantity,
price,
},
}
}"
}
{
"data": {
"orders": [
{
"id": 2,
"products": [
{
"name": "soap",
"price": 2.49,
"quantity": 4
}
],
"time": "2019-06-26T09:40:39.656311Z"
},
{
"id": 4,
"products": [
{
"name": "toothpaste",
"price": 3.49,
"quantity": 2
},
{
"name": "face wash",
"price": 8.99,
"quantity": 2
}
],
"time": "2019-06-29T09:40:39.6591Z"
}
]
}
}

Summary

We have had a look at a few GraphQL concepts, including strongly-typed schema, custom data types, resolvers, and flexible queries, and got basic understanding of how it works and what it can do. A more advanced topic that we have covered is database querying optimization using DataLoader.

Links

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store