📖 Click here for a fully-worked example of the code in this blog post

I'm building a GraphQL server to provide and API for a fictitious software consultancy firm. So far a user can query for a list of developers, their competencies and for their project assignments.

In this post I'm going to take this a bit further and look at how arguments are another useful tool in the GraphQL arsenal. They make your server more interactive, allowing for callers to be more specific when querying for data. With the use of GraphQL's introspection feature, it's also possible for a user to query which arguments are supported. Interactive environments like GraphiQL implement this feature to provide as-you-type auto-completion. It's a fantastic way of conveying the ability to "explore" your API surface to a user.

Arguments are added to fields, either at the root query level, or an individual field on a type. They're defined in the schema and they have too have types. I'm going to support some arguments in my GraphQL API, in order to demonstrate the different ways in which arguments can be used:

  • Getting the highest-rated N competencies for a developer - Optional Arguments
  • Getting an individual developer or project - Mandatory Arguments
  • Getting all assigned (or unassigned) developers
  • Getting skills, ordered A-Z or Z-A - Default Arguments

Let's dive in!

Getting the Highest-Rated Competencies

When defining new GraphQL arguments, I always like to think of a question that is being asked. Allowing to display the highest-rated competencies for the developers answers "What are the staff's specialisms?". This could be useful when the consultancy firm is looking for new projects to take on.

I already have a field that retrieves a list of competencies, on the Developer type. I add the new argument to this field - top, of type Int:

schema.js

type Developer {
  id: String!,
  name: String,
  competencies(top: Int): [Competency],
  role: Role,
  project: Project
}

Although the schema has changed, this is not a breaking change for existing callers. As the argument is optional it has no effect on any existing consumers of this field. The change is purely additive.

Before jumping into the underlying implementation, a quick recap. Previously the Developer class looked like this:

developer.js

class Develoepr {
  constructor(id, name, role) {
    this.id = id;
    this.name = name;
    this.role = toRole(role);
  }

  competencies(obj, ctx) {
    return getCompetenciesForDeveloper(this.id, ctx);
  }

  project(obj, ctx) {
    return getProjectAssignment(this.id, ctx);
  }
}

There are two functions that resolve data dynamically, to show how fields can be resolved asynchronously. The first argument to these functions was previously unused. When arguments are queried for on a particular field, they'll be defined on this first object. Therefore I retrieve the top argument from this object as such:

competencies({ top }, ctx) {
  return getCompetenciesForDeveloper(this.id, ctx, top);
}

The getCompetenciesForDeveloper function queries the database and maps the results to objects of type Competency. I have updated this to use the top argument, but as it's optional, it may not be defined. To achieve the effect of providing the highest n competencies for a given developer, the SQL query sorts the data and then (optionally) applies a restriction to the number of items returned:

api.js

export const getCompetenciesForDeveloper = (id, ctx, top) => {
  if (top < 0) {
    throw new Error('top cannot be negative');
  }
  const limit = top ? `LIMIT ${top}` : '';
  return ctx.db.all(
    `SELECT s.name, c.value FROM competencies c
     LEFT JOIN skill s ON (c.skillId = s.id)
     WHERE c.developerId = $id
     ORDER BY c.value DESC
     ${limit}`,
    { $id: id })
    .then(result => result.map(r => new Competency(r.name, r.value)));
}

Getting an Individual Developer or Project

Arguments can be specified as mandatory, meaning that they must be defined. GraphQL will return with an error if you execute a query with a mandatory argument missing. I can use this technique to define queries to return a single item: either a Developer or a Project. As with other mandatory fields in GraphQL, mandatory arguments are identified with a !. I add these new queries to the root query object:

schema.js

type Query {
  developer(id: String!): Developer,
  developers: [Developer],
  project(id: String!): Project,
  projects: [Project]
}

Retrieving the single item from the database is a simple SELECT WHERE query. If the result is not defined then an error is returned to the client instead:

api.js

export const getDeveloper = (ctx, id) =>
  ctx.db.get('SELECT id, name, role FROM developer WHERE id = $id', { $id: id })
  .then(result => result ?
    new Developer(result.id, result.name, result.role) :
    new Error(`No developer exists with id ${id}`));

export const getProject = (ctx, id) =>
  ctx.db.get('SELECT id, name, description FROM project WHERE id = $id', { $id: id })
  .then(result => result ?
    new Project(result.id, result.name, result.description) :
    new Error(`No project exists with id ${id}`))

A quick note about GraphQL error handling. With a REST API one would expect a 400, 404 or 500 depending on the context of the error. With GraphQL, you always get a 200 OK response, even for errors! This is because with GraphQL you're not always querying for a single item. Indeed, it's encouraged to query deeply nested data. The decision is therefore to "fail forward" - return what can be returned, and identify what aspects of the query failed.

To demonstrate this, for this example query:

{
  developer(id: "2") {
    id, name
  },
  project(id: "eggs") {
    id, name
  }
}

A response object is received with the following structure:

{
  "errors": [
    {
      "message": "No project exists with id eggs",
      "locations": [
        {
          "line": 5,
          "column": 3
        }
      ],
      "path": [
        "project"
      ]
    }
  ],
  "data": {
    "developer": {
      "id": "2",
      "name": "Gary"
    },
    "project": null
  }
}

A GraphQL response is always wrapped in the outer object with the errors and data keys, although errors is omitted in the case of a fully-successful query. As the project field on the query produced an error, it's set to null in the response, but a helpful error is also specified.

Should the default error format not be appropriate for you (perhaps displaying a location or path risks a leakage of implementation detail), then there are projects out there that can improve or customise GraphQL errors.

Get all assigned (or unassigned) Developers

I can imagine a project manager in a resourcing meeting wishing to see at-a-glance which developers are currently assigned to a project, or are still waiting assignment.

As I have an existing query to return all developers, I can make a simple adjustment to define an assigned argument of type Boolean. By leaving it optional I am indicating that if it's not present, it will fetch all developers. Again, this makes the change non-breaking for existing callers.

The root query now becomes:

schema.js

type Query {
  developer(id: String!): Developer,
  developers(assigned: Boolean): [Developer],
  project(id: String!): Project,
  projects: [Project]
}

This new argument is passed through to the API-layer function, just like the previous examples:

schema.js

export const rootValue = {
  developer: ({ id }, ctx) => getDeveloper(ctx, id),
  developers: ({ assigned }, ctx) => getDevelopers(ctx, assigned),
  project: ({ id }, ctx) => getProject(ctx, id),
  projects: (obj, ctx) => getProjects(ctx)
};

The getDevelopers function itself is inherently now more complicated. The SELECT aspect is still the same, but I now optionally add a WHERE clause if the assigned argument is defined. Remember that in JavaScript false and undefined are actually different but as undefined is falsey, !assigned is true for both values. You need to be more specific with your undefined checks!

When the argument is set, I build up a subquery and concatenate it to the SELECT statement. ES6 string templating makes this approach simple. In SQL, a WHERE EXISTS statement lets you filter a result set based on the success of a sub-query. In this case, the sub-query asks if there is an entry in the assignments table for a given developer's id. WHERE NOT EXISTS, as you'll expect, does the inverse:

api.js

export const getDevelopers = (ctx, assigned) => {
  const subquery = assigned === undefined ?
    '' :
    `${assigned ? 'WHERE' : 'WHERE NOT'} EXISTS (SELECT a.developerId FROM assignments a WHERE a.developerId = d.id)`;
  const query = `SELECT d.id, d.name, d.role FROM developer d ${subquery}`;

  return ctx.db.all(query)
  .then(result => result.map(r => new Developer(r.id, r.name, r.role)));
};

Getting Skills, Sorted Alphabetically or Reverse-Alphabetically

So far I've only exposed competencies in terms of a developer, where it comes alongside with an ability rating. But on a more general level, a caller could wish to just see a raw list of all the skills in the organisation. I could also imagine these wishing to be sorted either A-Z, or Z-A.

It's possible to use an enum as the type of a particular argument. Furthermore, with any argument, you can also supply a default value. If the caller does not specify the argument, then the default value is taken. If you're wishing to add a new mandatory argument to an existing field, you can retain backwards-compatibility this way.

A Skill is an entity with a unique id and a name. I'll also need an enum for a sort order, and a new field on the root query:

schema.js

type Skill {
  id: String!,
  name: String
}

enum Order {
  ASCENDING, DESCENDING
}

type Query {
  developer(id: String!): Developer,
  developers(assigned: Boolean): [Developer],
  project(id: String!): Project,
  projects: [Project],
  skills(order: Order = ASCENDING) : [Skill]
}

This results in a new resolver function:

schema.js

export const rootValue = {
  developer: ({ id }, ctx) => getDeveloper(ctx, id),
  developers: ({ assigned }, ctx) => getDevelopers(ctx, assigned),
  project: ({ id }, ctx) => getProject(ctx, id),
  projects: (obj, ctx) => getProjects(ctx),
  skills: ({ order }, ctx) => getSkills(ctx)
};

I've also defined a JavaScript clas for the new Skill type:

skill.js

class Skill {
  constructor(id, name) {
    this.id = id;
    this.name = name;
  }
}

export default Skill;

When querying the database I add an ORDER BY to the selection from the skills table. The direction of the ordering is handled depending on the argument, respecting the default direction. Again, ES6 templating makes this incredibly easy:

api.js

export const getSkills = (ctx, order) =>
  ctx.db.all(`SELECT id, name FROM skill ORDER BY name ${order === 'DESCENDING' ? 'DESC' : 'ASC'}`)
  .then(result => result.map(r => new Skill(r.id, r.name)));

Next Steps

GraphQL makes it incredibly easy to add arguments to any of your queries and they're fully integrated into the schema's type system. In this post I actually thought the most difficult aspect was ensuring that an optional argument was set (false rather than undefined, as JavaScript coercion can catch you out here) and translating that into the appropriate SQL query.

In the examples above, I'm sorting and filtering the data that's returned to the caller. I've chosen to implement these at the database layer, in the SQL queries. Remember though that as GraphQL makes no assumptions about where it retrieves its data from, it may be necessary for you to apply the effects of your arguments somewhere else, even possibly in the resolver functions directly. That's absolutely fine and entirely your decision to make. The context of your application and profiling tools will assist you in determining where to make the choice. You may be retrieving your data from a static text file and have to do the .filter yourself!

The API is currently one way - the work in this post only allows for a caller to be a bit more specific when asking for data. It would be nice if the user could post back data, just like a HTTP POST request would in a REST API. In the next post, I'll take a look into how GraphQL's mutations can be used for this purpose.