Building a composable query generator for GraphQL

justinweiss

Justin Weiss

Posted on January 13, 2023

Building a composable query generator for GraphQL

In a lot of newer projects, we use our GraphQL API. This is the same API you use when you're building Aha! Develop extensions. GraphQL has given us a more consistent, more flexible, and often more efficient way to access the data inside an Aha! account.

GraphQL queries are just strings:

const query = `{
  feature(id: "DEMO-29") {
    id
    name
  }
}`;

const response = await fetch("/api/v2/graphql", {
  "headers": {
    "Content-Type": "application/json",
  },
  "method": "POST",
  "body": JSON.stringify({ query }),
});
Enter fullscreen mode Exit fullscreen mode

Strings are easy to start with and work well for simple, mostly static queries. But what about something more complex? What if you wanted the name of the person assigned to each feature but not when you're looking at a list of your own features? What if only some teams used sprints and you didn't want sprint information if a team didn't use them? How could you easily make wildly customizable views and only fetch the data you needed for that specific view?

Simple interpolation is one option:

const sprintQuery = sprintsEnabled ? `{
  sprint {
    id
    name
    startDate
    duration
  }
}` : "";

const query = `{
  features {
    id
    name
    ${ sprintQuery }
  }
}`;
Enter fullscreen mode Exit fullscreen mode

But this can quickly get out of hand. You need to know in advance how a query can be modified so you can be sure to leave room for strings to be interpolated inside of them. Handling optional arguments can be a pain. So if strings aren't the right option for very dynamic queries, what is?

The next step beyond strings

There's a common solution to this problem: Store the information you need to make your final decisions in a more raw form that you can look at. Keep them in a structure — an introspectable form — until you're ready to generate a result.

What does that look like, though?

Say a part of your code wanted a few fields from a Feature API, "id", and "name". Some other code wants the reference number too. Instead of building a query as a string and interpolating fields into it, like this:

const query = `{
  features {
    id
    name
    ${ maybeReferenceNumber }
  }
}`;
Enter fullscreen mode Exit fullscreen mode

You could keep a list of fields:

const fields = ["id", "name"];
Enter fullscreen mode Exit fullscreen mode

Other parts of your system could add to it:

fields.push("referenceNumber");
Enter fullscreen mode Exit fullscreen mode

And when you generate your query, the query has all of the information it needs right at hand:

const query = `{
  features {
    ${ fields.join("\n") }
  }
}`;
Enter fullscreen mode Exit fullscreen mode

This seems like a minor change, and it is, but it's powerful. An object, which is easy to work with, keeps track of the decisions you've made up to this point. Your query generation is simple — it just does what the object tells it to do. Here, that's turning an array of names into fields in the query. This opens up a lot of possibilities but first we can clean it up.

How to store the query state

If you've ever used Rails, you'll know that it's great at building queries over time. You call methods to build up a query and only fire it off when you're done with it:

query = account.features
query = query.where(project_id: project.id) if project
query = query.where(release_id: release.id) if release
# ...
Enter fullscreen mode Exit fullscreen mode

I've been surprised that more languages or frameworks haven't been influenced by this query building. To me, it's one of the best parts of Rails.

You can build something like this with GraphQL, though, keeping your decisions in an object and generating a query at the last minute. Imagine a Query class. We'll keep it simple to start, just holding a query type and a list of fields:

class Query {
  constructor(type) {
    this.type = type;
    this.fields = new Set();
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, you can implement a simple "select" method to select ID and name:

class Query {
  // ...
  select(fields) {
    fields.forEach(field => {
      this.fields.add(attr);
    });

    return this;
  }
}

let query = new Query("features");
query.select(["id", "name"]);
Enter fullscreen mode Exit fullscreen mode

You can pass that query object around and other functions can add to that query object:

query.select(["referenceNumber"]);
Enter fullscreen mode Exit fullscreen mode

Now, when you need it, the Query object can generate a query string. Just like the example above:

class Query {
  // ...
  toString() {
    return `{
      ${ this.type } {
        ${ Array.from(this.fields).join("\n") }
      }
    }`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Once you have this string, you can hand it to whichever GraphQL library you're using as if you wrote it yourself.

With that simple pattern, you can go further. You can add arguments to filter the results:

class Query {
  constructor(type) {
    // ...
    this.filters = {};
  }

  // ...
  where(filters = {}) {
    this.filters = { ...this.filters, ...filters };
    return this;
  }

  processFilters(filters) {
    // Very basic conversion, can be improved as necessary
    return Object.keys(filters)
      .map(k => `${k}: ${JSON.stringify(filters[k])}`)
      .join(', ');
  }

  toString() {
    let args = '';
    if (Object.keys(this.filters).length > 0) {
      args = `(filters: {${this.processFilters(this.filters)}})`;
    }

    return `{
      ${ this.type }${ args } {
        ${ Array.from(this.fields).join("\n") }
      }
    }`;
  }
}

let query = new Query("features");
query.select(["id", "name"]).where({ assigneeId: "12345" }).toString();
Enter fullscreen mode Exit fullscreen mode

You can add subqueries to select information from child objects:

class Query {
  constructor(type) {
    // ...
    this.subqueries = [];
  }

  // ...
  merge(subquery) {
    this.subqueries.push(subquery);
    return this;
  }

  toString(options = {}) {
    const { asSubquery } = options;

    let args = '';
    if (Object.keys(this.filters).length > 0) {
      args = `(filters: {${this.processFilters(this.filters)}})`;
    }

    const subqueryString = `${this.type}${args} {
        ${Array.from(this.fields).join('\n')}
        ${this.subqueries.map(s => s.toString({ asSubquery: true })).join('\n')}
      }`;

    if (asSubquery) {
      return subqueryString;
    } else {
      return `{
        ${subqueryString}
      }`;
    }
  }}
Enter fullscreen mode Exit fullscreen mode

And now you can build arbitrarily complicated queries based on whatever state you have:

let query = new Query('features');
query.select(['id', 'name']).where({ assigneeId: '12345' });

if (projectId) {
  query.where({ projectId });
}

if (includeRequirements) {
  let requirementsQuery = new Query('requirements');
  requirementsQuery.select(['id', 'name', 'position']);

  query.merge(requirementsQuery);
}

query.toString();
Enter fullscreen mode Exit fullscreen mode

From now on, any time you want to change a query based on information you have someplace else, it's as easy as calling a method.

What's next?

This query generator is simple and is still missing a lot, but the core idea is easy to extend to fit your API:

  • You can add functionality for sorting, passing query arguments, pagination, etc.
  • You can have it generate GraphQL fragments and variables instead of a single string.
  • You can add another layer. For example, have your query framework generate Apollo documents instead of strings and you can get better editor integration and error messages.
  • You can create a mutation builder, tracking changes you make to your JavaScript objects and generating mutations based on those changes.

We've done all of those things, and found a lot of value in a flexible generator like this. Building a query generator ourselves also allowed us to integrate it more closely with other parts of our application framework. It's a nice simple base we could build off of easily.

And once you have this base — an introspectable object that can generate the strings you need — your code no longer needs to worry about the actual queries. It's just responsible for the decision-making and the generator does the rest.


Aha! is happy, healthy, and hiring. Join us!

We are challenged to do great work and have fun doing it. If you want to build lovable software with this talented and growing team, apply to an open role.

💖 💪 🙅 🚩
justinweiss
Justin Weiss

Posted on January 13, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related