How to build relay-compliant GraphQL connections to list connected objects in GolangJonathan Barney
Jonathan Barney
jonathan.b@aptusai.com
Published on Tue Mar 02 2021

When trying to build a relay compliant API in golang for one of my recent projects, I found that one of the most complicated parts to construct was the relay-compliant connections for listing connected objects with a backend database using the GORM ORM. For simple cases the github.com/graphql-go/relay library is fantastic. It provides methods which greatly simplify the creation of connections.

The below example is taken from the examples section of the library repository:

	shipConnectionDefinition := relay.ConnectionDefinitions(relay.ConnectionConfig{
		Name:     "Ship",
		NodeType: shipType,
	})

	factionType = graphql.NewObject(graphql.ObjectConfig{
		Name:        "Faction",
		Description: "A faction in the Star Wars saga",
		Fields: graphql.Fields{
			"id": relay.GlobalIDField("Faction", nil),
			"name": &graphql.Field{
				Type:        graphql.String,
				Description: "The name of the faction.",
			},
			"ships": &graphql.Field{
				Type: shipConnectionDefinition.ConnectionType,
				Args: relay.ConnectionArgs,
				Resolve: func(p graphql.ResolveParams) (interface{}, error) {
					// convert args map[string]interface into ConnectionArguments
					args := relay.NewConnectionArguments(p.Args)

					// get ship objects from current faction
					ships := []interface{}{}
					if faction, ok := p.Source.(*Faction); ok {
						for _, shipId := range faction.Ships {
							ships = append(ships, GetShip(shipId))
						}
					}
					// let relay library figure out the result, given
					// - the list of ships for this faction
					// - and the filter arguments (i.e. first, last, after, before)
					return relay.ConnectionFromArray(ships, args), nil
				},
			},
		},
		Interfaces: []*graphql.Interface{
			nodeDefinitions.NodeInterface,
		},
	})

Note that when we have the entire list of possible (ship) objects in memory we can easily wrap the array with:

relay.ConnectionFromArray(ships, args)

and the relay library will handle all the of connection filters and pagination for us.

For large data sets, loading the entire data set into memory is not performant or even desirable. For situations like this the graphql-relay-go library provides the ConnectionFromArraySlice method

func ConnectionFromArraySlice(
	arraySlice []interface{},
	args ConnectionArguments,
	meta ArraySliceMetaInfo,
) *Connection

In this case we must simply pass in a slice of the array that covers at least the entire range being requested. From the graphql-relay-go docs:

/*
Given a slice (subset) of an array, returns a connection object for use in
GraphQL.
This function is similar to `ConnectionFromArray`, but is intended for use
cases where you know the cardinality of the connection, consider it too large
to materialize the entire array, and instead wish pass in a slice of the
total result large enough to cover the range specified in `args`.
*/

By using this function we gain the freedom to load exactly what we want to load from the DB and implement custom filters and sorts.

Unfortunately, the documentation on how to use this function is somewhat lacking and we are left to our own devices to determine how to use it.

The ConnectionArguments object is passed into our graphql.FieldResolveFn as a the Args field, but of type map[string]interface{} . We must convert the args to relay-args using

relayArgs = relay.NewConnectionArguments(args)

so we now have args of type relay.ConnectionArguments .

The relay.ArraySliceMetaInfo we must construct from our own information about how much data there is that fulfills whatever custom filters we have on the data. With GORM you can do something like

var count int64
db := DB.Model(&Membership{}).Where("memberships.organization_id = ?", thisOrganization.ID)
result := db.Count(&count)

to count the valid records in our connection. We can also determine the relay.ArraySliceMetaInfo.SliceStart from the offset cursor that is passed into as connection args. We will need this to offset the records we grab from the database as well.

I found the relay.ConnectionArgument type difficult to work with because it save the offsets as cursors that must be decoded to determine offset, so I created my own custom connection arguments to use while calculating sorts and offsets. I also created a helper method to translate the relay connection arguments to my custom connection arguments type. In this type the before and after are integer indices instead of opaque string cursors.

type customConnectionArguments struct {
	Before int
	After  int
	First  int
	Last   int
}

func toCustomConnection(old relay.ConnectionArguments) (customConnectionArguments, error) {
	cca := customConnectionArguments{-1, -1, old.First, old.Last}
	var err error
	if old.After != "" {
		cca.After, err = relay.CursorToOffset(old.After)
		if err != nil {
			return cca, err
		}
	}
	if old.Before != "" {
		cca.Before, err = relay.CursorToOffset(old.Before)
		if err != nil {
			return cca, err
		}
	}
	if cca.First != -1 && cca.Last != -1 {
		return cca, errors.New("cannot specify both first and last")
	}

	return cca, nil
}

Now that we better understand the relay library interface and have our setup, we can worry about solving the actual problem of implementing the relay connection pagination on the DB side. The relay connection spec requires that we be able to use cursors to offset from either the beginning or the end of our data a certain amount, while maintaining the data in the same order. SQL has no REVERSE OFFSET, so in order to offset from the end of results we must first reverse the sorts on the data, do the offset, then reverse the sort again to get our original sort, then do the forwards offset.

Fortunately, we can do something like this programatically with GORM without too much difficulty through the use for GORM scopes. This we we can apply filters, and specify sorts, then call a single function to handle the SQL pagination, then return the results with our parameters.

Because we must reverse the sorts, I have added a simple way to specify to our SQL Paginator what sorts we wish to apply and in what order through a Sort type.

//Sort is sort that we wish to apply to the results
type Sort struct {
	Field string
	Asc   bool
}

We can pass in an ordered array of these to the pagination function so it knows how to add the reverse sorts.

Our pagination function will return a GORM scope function (count is the count of possible records from above)

func Paginate(cca customConnectionArguments, sort []Sort, count int) func(db *gorm.DB) *gorm.DB {
	return func(db *gorm.DB) *gorm.DB {

I have also added a default sort of id in case we do not wish to specify a sort

sort := append(sort, Sort{"id", true})

Passed into the scope function we get the starting DB with any necessary filters applied, then we apply the sorts, offsets, and limits as specified by the connection args. We are working from the back of the results to the front here, so we can specify how far from the end we wish to go and how many results from the end we desire.

	//Reverse order, do offset and limit
		var subDB = db
		//reverse order
		for _, s := range sort {
			st := " asc"
			if s.Asc {
				st = " desc"
			}
			subDB = subDB.Order(subDB.Statement.Table + "." + s.Field + st)
		}
		//Do Offset
		if cca.Before != -1 {
			subDB = subDB.Offset(count - cca.Before)
		}
		//Do Limit
		if cca.Last != -1 {
			subDB = subDB.Limit(cca.Last)
		} else {
			subDB = subDB.Limit(count)
		}

Then the magic part is we use the results of that query as the starting point for our next results by doing

db = DB.Table("(?) as u", subDB)

We can then reapply sorts, offsets, and limits going in the forwards direction

		//reverse order again.
		for _, s := range sort {
			st := " asc"
			if !s.Asc {
				st = " desc"
			}
			db = db.Order(s.Field + st)
		}
		//Limit to the desired amount
		if cca.First != -1 {
			db = db.Limit(cca.First)
		} else {
			db = db.Limit(count)
		}
		//Offset
		db = db.Offset(cca.After + 1)

		return db

Having applied this double reverse sort, offset, and limit, we can then run our find and returned the desired results already filtered and sorted as we have specified.

The last piece of the puzzle is being able to specify to the graphql-relay-go library what the first member of our slice is, which we must determine based on the amount of results we received and whether we are going from the end of the beginning of the total result set. That would look something like

metaInfo = relay.ArraySliceMetaInfo{cca.After + 1, int(count)}
if cca.Last != -1 {
    if cca.Before == -1 {
        metaInfo.SliceStart = int(count) - len(returnedObjects)
    } else {
        metaInfo.SliceStart = cca.Before - len(returnedObjects)
    }
}

where count is the length of the set of all results and returnedObjects is the array of objects returned from the db query.

All together, the code could look something like the following

//First create the relay connection definition to add as a field on the schema type
membershipsConnectionDefinition := relay.ConnectionDefinitions(relay.ConnectionConfig{
	Name:     "OrganizationMembership",
	NodeType: MembershipType,
})

//Then add the field to the schema specifying the resolve function, and connection type
OrganizationType.AddFieldConfig("memberships", &graphql.Field{
	Type:    membershipsConnectionDefinition.ConnectionType,
	Args:    relay.ConnectionArgs,
	Resolve: organizationResolve(resolveOrganizationMemberships),
})

type resolveOrganizationFunc func(context.Context, *models.Organization, map[string]interface{}) ([]interface{}, relay.ArraySliceMetaInfo, relay.ConnectionArguments, error)

//This lets us keep our code more DRY
func organizationResolve(thisFunc resolveOrganizationFunc) graphql.FieldResolveFn {
	return func(p graphql.ResolveParams) (interface{}, error) {
		val, ok := p.Source.(*models.Organization)
		if !ok {
			return nil, errors.New("organization source not organization")
		}
		data, metaInfo, relayArgs, err := thisFunc(p.Context, val, p.Args)
		if err != nil {
			return nil, err
		}
		return relay.ConnectionFromArraySlice(data, relayArgs, metaInfo), nil
	}
}
//We have to transform whatever is returned into an []interface{}
func resolveOrganizationMemberships(ctx context.Context, thisOrganization *models.Organization, args map[string]interface{}) (data []interface{}, metaInfo relay.ArraySliceMetaInfo, relayArgs relay.ConnectionArguments, err error) {
	thisResponse, metaInfo, relayArgs, err := GetOrganizationMemberships(ctx, thisOrganization, args)
	for _, val := range thisResponse {
		data = append(data, val)
	}
	return data, metaInfo, relayArgs, err
}


//GetOrganizationMemberships is to get the Organization Memberships specifying pagination sorts
func GetOrganizationMemberships(ctx context.Context, thisOrganization *Organization, args map[string]interface{}) (thisMemberships []*Membership, metaInfo relay.ArraySliceMetaInfo, relayArgs relay.ConnectionArguments, err error) {
	//transform first to relay args then to custom connection args
	relayArgs = relay.NewConnectionArguments(args)
	cca, err := toCustomConnection(relayArgs)
	if err != nil {
		return
	}
    //add any sorts we want to use here
	sort := []Sort{}
	var count int64
    //set up filters we want on the data
	db := DB.Model(&Membership{}).Where("memberships.organization_id = ?", thisOrganization.ID)
    //count the amount of data included in these filters
	result := db.Count(&count)
	if result.Error != nil {
		err = result.Error
		return
	}
	if count == 0 {
		err = ErrNotAuthorized
		return
	}
    //Then do the magic offsets and limits in the SQL before the run the query
	result = db.Scopes(Paginate(cca, sort, int(count))).Find(&thisMemberships)

	metaInfo = relay.ArraySliceMetaInfo{cca.After + 1, int(count)}
	if cca.Last != -1 {
		if cca.Before == -1 {
			metaInfo.SliceStart = int(count) - len(thisMemberships)
		} else {
			metaInfo.SliceStart = cca.Before - len(thisMemberships)
		}
	}
	return thisMemberships, metaInfo, relayArgs, result.Error
}
//Our custom connection type
type customConnectionArguments struct {
	Before int
	After  int
	First  int
	Last   int
}

func toCustomConnection(old relay.ConnectionArguments) (customConnectionArguments, error) {
	cca := customConnectionArguments{-1, -1, old.First, old.Last}
	var err error
	if old.After != "" {
		cca.After, err = relay.CursorToOffset(old.After)
		if err != nil {
			return cca, err
		}
	}
	if old.Before != "" {
		cca.Before, err = relay.CursorToOffset(old.Before)
		if err != nil {
			return cca, err
		}
	}
	if cca.First != -1 && cca.Last != -1 {
		return cca, errors.New("cannot specify both first and last")
	}

	return cca, nil
}

//Sort is a sort field
type Sort struct {
	Field string
	Asc   bool
}

//Paginate is used to paginate
func Paginate(cca customConnectionArguments, sort []Sort, count int) func(db *gorm.DB) *gorm.DB {
	return func(db *gorm.DB) *gorm.DB {
		sort := append(sort, Sort{"id", true})

		//Reverse order, do offset and limit
		var subDB = db
		//reverse order
		for _, s := range sort {
			st := " asc"
			if s.Asc {
				st = " desc"
			}
			subDB = subDB.Order(subDB.Statement.Table + "." + s.Field + st)
		}
		//Do Offset
		if cca.Before != -1 {
			subDB = subDB.Offset(count - cca.Before)
		}
		//Do Limit
		if cca.Last != -1 {
			subDB = subDB.Limit(cca.Last)
		} else {
			subDB = subDB.Limit(count)
		}

		db = DB.Table("(?) as u", subDB)

		//reverse order again.
		for _, s := range sort {
			st := " asc"
			if !s.Asc {
				st = " desc"
			}
			db = db.Order(s.Field + st)
		}
		//Limit to the desired amount
		if cca.First != -1 {
			db = db.Limit(cca.First)
		} else {
			db = db.Limit(count)
		}
		//Offset
		db = db.Offset(cca.After + 1)

		return db
	}
}

This code gives us the ease of specifying whatever filters and sorts are necessary for our relay compliant connection, with the hard work being done by the DB, graphql-relay-go and GORM.


Whether you're building your own GraphQL server or you have a new application idea you're looking to find the right technology partner for, Aptus help!

Contact Lindsay to schedule a FREE 30-min introductory consultation with a solutions architect to help you figure out how to bring your ideas to life!